context en Go: cancelación, timeout, valores y propagación entre goroutines

El paquete context de Go resuelve un problema real: cómo cancelar una operación en curso que involucra varias goroutines y llamadas a servicios externos. En lugar de pasar múltiples canales de cancelación, un context.Context propaga la cancelación, los timeouts y los valores a lo largo de toda la cadena de llamadas con una única interfaz.

La interfaz Context

Context tiene cuatro métodos. Done() devuelve un canal que se cierra cuando se cancela el contexto. Err() explica por qué. Deadline() indica cuándo expira. Value(key) recupera valores:

type Context interface {
    Done() <-chan struct{}
    Err() error        // context.Canceled o context.DeadlineExceeded
    Deadline() (deadline time.Time, ok bool)
    Value(key any) any
}

context.WithCancel

WithCancel devuelve un contexto derivado y una función cancel que al llamarla cierra el canal Done(). El defer cancel() es imprescindible para liberar recursos aunque la operación termine antes:

package main

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

func operacionLarga(ctx context.Context) error {
    select {
    case <-time.After(5 * time.Second):
        return nil // terminó bien
    case <-ctx.Done():
        return ctx.Err() // cancelado o timeout
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    go func() {
        time.Sleep(2 * time.Second)
        cancel() // cancela desde otra goroutine
    }()

    err := operacionLarga(ctx)
    fmt.Println(err) // context canceled
}

context.WithTimeout y WithDeadline

WithTimeout cancela el contexto automáticamente pasado el tiempo indicado. WithDeadline acepta un momento absoluto:

func buscarConDB(query string) ([]string, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // La query se cancela si tarda más de 3 segundos
    rows, err := db.QueryContext(ctx, "SELECT nombre FROM usuarios WHERE rol = $1", query)
    if err != nil {
        return nil, fmt.Errorf("consulta fallida: %w", err)
    }
    defer rows.Close()

    var resultados []string
    for rows.Next() {
        var nombre string
        rows.Scan(&nombre)
        resultados = append(resultados, nombre)
    }
    return resultados, rows.Err()
}

context.WithValue: pasar datos entre goroutines

WithValue adjunta un valor al contexto que se propaga automáticamente a todos los contextos derivados. Usa tipos propios como clave para evitar colisiones:

type contextKey string

const claveRequestID contextKey = "request_id"

func middlewareID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := generarID()
        ctx := context.WithValue(r.Context(), claveRequestID, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func handler(w http.ResponseWriter, r *http.Request) {
    id, ok := r.Context().Value(claveRequestID).(string)
    if !ok {
        http.Error(w, "sin request ID", http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, "petición %s procesada", id)
}

Propagación en servidores HTTP

El contexto de una petición HTTP llega cancelado cuando el cliente desconecta. Propágalo a todas las llamadas descendientes para no seguir trabajando en vano:

func apiHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // lleva cancelación automática del cliente

    // Añade timeout adicional para la llamada externa
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    resultado, err := llamadaExterna(ctx)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            http.Error(w, "timeout en servicio externo", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    fmt.Fprintln(w, resultado)
}

func llamadaExterna(ctx context.Context) (string, error) {
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    // leer respuesta...
    return "ok", nil
}

Reglas de uso del contexto

  • El primer argumento de cualquier función que haga I/O o llamadas de red debería ser ctx context.Context.
  • Nunca almacenes un contexto en una estructura; pásalo explícitamente.
  • Usa context.Background() en el arranque de la aplicación y en tests.
  • Usa context.TODO() como marcador temporal cuando aún no sabes qué contexto usar.
  • Llama siempre al cancel devuelto, aunque sea con defer, para no perder recursos.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP