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.WithCancelCauseen lugar deWithCancelsiempre 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
selectcon<-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
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.