Propagación de cancelación y timeouts con context

Gestionar la vida útil de una operación que atraviesa múltiples capas de abstracción es uno de los retos más críticos en sistemas distribuidos. context.Context [disponible desde Go 1.7] es una interfaz diseñada para resolver este problema, permitiendo propagar señales de cancelación, límites temporales (timeouts/deadlines) y valores con ámbito de solicitud a través de los límites de un proceso.

El diseño de context como un parámetro explícito en lugar de una variable global o un campo de una estructura responde a una decisión de arquitectura fundamental: la visibilidad del flujo de control. Al pasar el contexto como el primer argumento de una función, el programador deja claro que esa función es sensible al ciclo de vida de la operación que la invocó. Si una función puede bloquearse (esperando I/O, una respuesta de red o un canal), debe aceptar un ctx context.Context. Si ignoras esta práctica, el sistema puede sufrir fugas de goroutines (goroutine leaks), donde procesos quedan ejecutándose indefinidamente en segundo plano, consumiendo memoria y CPU para resultados que nadie está esperando.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// RequestIDKey es una clave de tipo privado para evitar colisiones en el contexto.
type requestIDKey struct{}

func main() {
	// Creamos un contexto raíz.
	rootCtx := context.Background()

	// Inyectamos un valor de metadatos (Request ID) para rastreo.
	// Los valores en context deben ser para datos de alcance de la solicitud, no para parámetros de función.
	ctx := context.WithValue(rootCtx, requestIDKey{}, "req-12345")

	// Establecemos un límite de tiempo de 2 segundos para toda la operación.
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	// Es vital llamar a cancel para liberar recursos en cuanto la operación termine,
	// incluso si terminó antes del timeout.
	defer cancel()

	fmt.Println("Iniciando proceso...")
	if err := performDatabaseOperation(ctx); err != nil {
		fmt.Printf("Operación fallida: %v\n", err)
	} else {
		fmt.Println("Operación completada con éxito")
	}
}

func performDatabaseOperation(ctx context.Context) error {
	// Recuperamos el valor inyectado anteriormente.
	if reqID, ok := ctx.Value(requestIDKey{}).(string); ok {
		fmt.Printf("[Log] Ejecutando bajo el ID de solicitud: %s\n", reqID)
	}

	// Simulamos una operación de base de datos que tarda 3 segundos.
	// Como el timeout es de 2s, esta operación debería fallar.
	err := simulateWork(ctx)
	if err != nil {
		return err
	}

	return nil
}

func simulateWork(ctx context.Context) error {
	workDuration := 3 * time.Second
	timer := time.NewTimer(workDuration)
	defer timer.Stop()

	// El bloque select permite escuchar múltiples canales simultáneamente.
	// Es el mecanismo fundamental para que el contexto sea útil.
	select {
	case <-timer.C:
		// La tarea terminó normalmente.
		return nil
	case <-ctx.Done():
		// El contexto se canceló o el tiempo expiró.
		// ctx.Err() nos dirá la razón exacta (Canceled o DeadlineExceeded).
		return ctx.Err()
	}
}

Análisis del flujo

En el ejemplo anterior, el flujo de control se gestiona mediante una jerarquía de contextos. Comenzamos con context.Background(), que es el punto de partida para procesos principales.

  1. Inyección de valores: Usamos context.WithValue para añadir un requestIDKey. Fíjate que definimos un tipo requestIDKey vacío. Esta es una buena práctica para evitar colisiones de nombres: si cualquier librería externa usara la clave "id", sobrescribiría la nuestra. En el código, accedemos a él con un type assertion .(string) para recuperar el valor original.
  2. Gestión de límites: context.WithTimeout crea un contexto hijo que se cancelará automáticamente cuando pasen 2 segundos. Es crucial el uso de defer cancel(). Si la función performDatabaseOperation terminara en 100ms, el cronómetro del timeout seguiría corriendo en segundo plano hasta llegar a los 2s si no llamamos a cancel(), desperdiciando recursos del runtime.
  3. La escucha activa: En simulateWork, la función no solo espera a que termine un timer. Gracias al select, la goroutine queda bloqueada de forma eficiente hasta que ocurra una de dos cosas: el timer se dispara o el canal ctx.Done() emite una señal.
  4. Terminación limpia: Cuando el timer de 2 segundos se agota, el runtime cierra el canal ctx.Done(). El select detecta esta señal inmediatamente, la función retorna ctx.Err() y la pila de llamadas se libera.

El error frecuente

El error más común cuando se trabaja con concurrencia es lanzar una goroutine que recibe un contexto, pero no lo utiliza para controlar su propia ejecución.

// MAL: Esta función causa una fuga de goroutines.
func leakGoroutine(ctx context.Context) {
    go func() {
        // Aunque el contexto se cancele, esta goroutine 
        // seguirá bloqueada aquí por 10 años.
        time.Sleep(10 * time.Year) 
        fmt.Println("Esto nunca se ejecutará")
    }()
}

// BIEN: La goroutine respeta la señal de cancelación.
func safeGoroutine(ctx context.Context) {
    go func() {
        select {
        case <-time.After(10 * time.Second):
            fmt.Println("Terminado")
        case <-ctx.Done():
            fmt.Println("Limpiando recursos y saliendo...")
        }
    }()
}

La gestión de la cancelación no es una opción, es la base de la estabilidad en sistemas de alta concurrencia.

123

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio