Patrones avanzados de context

Avanzado
Go
Go
Actualizado: 19/04/2026

El paquete context creció en versiones recientes con varias funciones que amplían las posibilidades de cancelación. Más allá de WithCancel, WithTimeout y WithDeadline, las APIs modernas permiten propagar la causa de la cancelación, aislar ramas del árbol de contexto y registrar hooks que se ejecutan cuando el contexto finaliza. Esta lección se centra en esos patrones y su integración con errgroup.

Propagar la causa con WithCancelCause

La función context.WithCancel produce un contexto cuya cancelación queda registrada como context.Canceled. Cuando ocurre un error concreto (timeout de negocio, condición de parada, señal externa), saber que el contexto se canceló no basta: queremos saber por qué.

La función context.WithCancelCause devuelve un contexto junto con una función CancelCauseFunc que acepta un error. Ese error se consulta posteriormente con context.Cause:

package main

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

var ErrFallaNegocio = errors.New("regla de negocio rota")

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

    go func() {
        // ... trabajo ...
        cancel(ErrFallaNegocio)
    }()

    <-ctx.Done()

    fmt.Println("Error estándar:", ctx.Err())              // context canceled
    fmt.Println("Causa real:", context.Cause(ctx))         // regla de negocio rota
    fmt.Println("Comparación:", errors.Is(context.Cause(ctx), ErrFallaNegocio)) // true
}

ctx.Err() mantiene el contrato clásico (context.Canceled o context.DeadlineExceeded) para que el código existente siga funcionando. El valor real se consulta con context.Cause(ctx). Esta separación evita romper consumidores que solo comprueban ctx.Err().

Deadlines con causa

Existe también context.WithDeadlineCause y context.WithTimeoutCause para los casos en que quieras precisar el motivo del deadline o reemplazarlo por un error propio cuando venza:

ctx, cancel := context.WithTimeoutCause(
    parent,
    5*time.Second,
    fmt.Errorf("tiempo agotado esperando confirmación del banco"),
)
defer cancel()

if err := esperarConfirmacion(ctx); err != nil {
    if errors.Is(context.Cause(ctx), errTimeoutBanco) {
        // respuesta específica al timeout de negocio
    }
}

Este patrón es especialmente útil cuando un servicio compone varios timeouts y quieres mostrar al usuario un mensaje significativo, no un simple "context deadline exceeded".

Usa context.WithCancelCause en lugar de WithCancel siempre que tengas más de una razón posible de cancelación. El coste es nulo y el beneficio en diagnóstico es inmediato al revisar logs.

Aislarse del padre con WithoutCancel

Un escenario frecuente en servicios HTTP es este: el cliente cancela la petición mientras estamos ejecutando una tarea de auditoría o limpieza que debe completarse sí o sí. Si la tarea usa el mismo contexto que la petición, al cancelar el cliente se aborta también el trabajo pendiente.

La función context.WithoutCancel devuelve un contexto que preserva los valores del padre (request ID, usuario autenticado, logger) pero ignora la cancelación:

func manejarPedidoHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    if err := procesarPago(ctx, r); err != nil {
        http.Error(w, err.Error(), http.StatusBadGateway)
        return
    }

    // El cliente puede cortar aquí. Queremos que la auditoría termine de todos modos.
    auditoriaCtx := context.WithoutCancel(ctx)
    go registrarAuditoria(auditoriaCtx, r)

    fmt.Fprintln(w, "OK")
}

La goroutine de auditoría hereda el logger con request_id del contexto original pero ya no se cancela cuando la petición acaba. Eso evita perder información crítica cuando el usuario cierra la conexión justo antes de que se escriba el registro.

Hooks con AfterFunc

La función context.AfterFunc registra una función que se ejecuta cuando el contexto se cancela (por el motivo que sea). Devuelve una función de cancelación del propio hook por si queremos cancelarlo antes de que el contexto termine:

func subscribirNotificaciones(ctx context.Context, topic string) {
    sub, err := broker.Subscribe(topic)
    if err != nil {
        slog.Error("subscribe", "err", err)
        return
    }

    // Cuando el contexto se cierre, nos damos de baja
    stop := context.AfterFunc(ctx, func() {
        if err := sub.Unsubscribe(); err != nil {
            slog.Warn("unsubscribe", "topic", topic, "err", err)
        }
    })

    defer stop() // no ejecutar AfterFunc si salimos antes

    for msg := range sub.Messages() {
        procesarMensaje(ctx, msg)
    }
}

AfterFunc evita el patrón clásico con una goroutine que se quedaba bloqueada en <-ctx.Done() solo para ejecutar una limpieza. El runtime se encarga por nosotros y libera la goroutine cuando el contexto termina, reduciendo el consumo de memoria en sistemas con muchos contextos simultáneos.

Combinar context con errgroup

El paquete golang.org/x/sync/errgroup es una capa ligera sobre sync.WaitGroup que propaga el primer error que se produzca en cualquier goroutine del grupo y cancela automáticamente el contexto compartido. Es el patrón idiomático para paralelizar trabajo atado a un contexto padre:

import "golang.org/x/sync/errgroup"

func cargarPaginaUsuario(ctx context.Context, userID int64) (*Pagina, error) {
    g, ctx := errgroup.WithContext(ctx)

    var perfil *Perfil
    var pedidos []Pedido
    var notificaciones []Notificacion

    g.Go(func() error {
        p, err := cargarPerfil(ctx, userID)
        if err != nil {
            return fmt.Errorf("perfil: %w", err)
        }
        perfil = p
        return nil
    })
    g.Go(func() error {
        p, err := cargarPedidos(ctx, userID)
        if err != nil {
            return fmt.Errorf("pedidos: %w", err)
        }
        pedidos = p
        return nil
    })
    g.Go(func() error {
        n, err := cargarNotificaciones(ctx, userID)
        if err != nil {
            return fmt.Errorf("notificaciones: %w", err)
        }
        notificaciones = n
        return nil
    })

    if err := g.Wait(); err != nil {
        return nil, err
    }
    return &Pagina{Perfil: perfil, Pedidos: pedidos, Notificaciones: notificaciones}, nil
}

Cuando una de las tres funciones falla, errgroup.WithContext cancela el ctx derivado. Las otras goroutines, si respetan el contexto, reciben la cancelación en cuanto intenten bloquearse en una operación de E/S y abortan limpiamente. El resultado es un patrón de concurrencia estructurada sencilla de leer y depurar.

flowchart LR
    A[errgroup.WithContext] --> B[goroutine perfil]
    A --> C[goroutine pedidos]
    A --> D[goroutine notificaciones]
    B -- falla --> E[cancel ctx]
    E --> C
    E --> D
    B --> F[g.Wait returns err]
    C --> F
    D --> F

Limitar el paralelismo con SetLimit

A partir de Go moderno, errgroup.Group tiene el método SetLimit(n) para establecer un máximo de goroutines concurrentes. Esto es vital cuando el trabajo a paralelizar es masivo (miles de peticiones) y no quieres saturar la base de datos o un servicio externo:

func procesarLote(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(16) // máximo 16 goroutines en paralelo

    for _, it := range items {
        it := it
        g.Go(func() error {
            return procesarItem(ctx, it)
        })
    }
    return g.Wait()
}

SetLimit actúa como un semáforo implícito: g.Go se bloquea cuando ya hay 16 goroutines activas y espera a que alguna termine antes de lanzar la siguiente.

Patrón producer-consumer con contexto

Un patrón recurrente combina errgroup con canales para construir pipelines. El productor genera trabajos, varios consumidores los procesan y todos responden al mismo contexto:

func pipelineCargaFichero(ctx context.Context, ruta string) error {
    g, ctx := errgroup.WithContext(ctx)
    lineas := make(chan string)

    g.Go(func() error {
        defer close(lineas)
        f, err := os.Open(ruta)
        if err != nil {
            return fmt.Errorf("abrir %s: %w", ruta, err)
        }
        defer f.Close()
        sc := bufio.NewScanner(f)
        for sc.Scan() {
            select {
            case lineas <- sc.Text():
            case <-ctx.Done():
                return ctx.Err()
            }
        }
        return sc.Err()
    })

    // 4 workers consumidores
    for i := 0; i < 4; i++ {
        g.Go(func() error {
            for l := range lineas {
                if err := procesarLinea(ctx, l); err != nil {
                    return err
                }
            }
            return nil
        })
    }

    return g.Wait()
}

El productor se encarga de cerrar el canal cuando acaba de leer. Los workers salen del for range automáticamente y devuelven nil. Si el productor falla, errgroup cancela el contexto y los workers lo detectan al intentar escribir en el canal o al invocar procesarLinea.

En un pipeline de este tipo, el select con <-ctx.Done() en el productor es esencial. Sin él, si todos los workers fallan y el contexto se cancela, el productor seguiría intentando enviar al canal y se quedaría bloqueado indefinidamente.

Evitar dependencias circulares con los valores del contexto

context.WithValue sigue siendo útil para propagar valores como el request ID o el logger, pero hay que usarlo con disciplina:

  • 1. Limita los valores al metadato transversal (request ID, usuario autenticado, tenant). No uses el contexto como un diccionario de argumentos.

  • 2. Usa un tipo privado como clave para evitar colisiones con otros paquetes:

type ctxKey struct{ name string }

var userKey = ctxKey{"user"}

func WithUsuario(ctx context.Context, u *Usuario) context.Context {
    return context.WithValue(ctx, userKey, u)
}

func UsuarioDesde(ctx context.Context) *Usuario {
    u, _ := ctx.Value(userKey).(*Usuario)
    return u
}
  • 3. Documenta en el paquete qué valores se esperan en el contexto y qué se pone con context.WithValue. Sin documentación, los consumidores no saben qué hay disponible.

La cancelación, las causas y los hooks forman hoy el núcleo de un servicio Go bien construido. Integrarlos desde el principio evita refactorizaciones profundas cuando el tráfico aumenta o cuando entra en escena un nuevo cliente con requisitos de timeout distintos.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Go es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Go

Explora más contenido relacionado con Go y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Aplicar context.WithCancelCause y context.Cause para propagar razones detalladas, usar WithoutCancel en tareas de limpieza, registrar hooks con AfterFunc y paralelizar trabajo con errgroup respetando el contexto padre.