Un timeout en diez líneas
El trabajo de context.Context es decirle al código cuándo parar. Aquí una operación lenta tiene 50 ms y se rinde cuando el context lo indica:
La primera llamada termina en 10 ms y devuelve rows <nil>. La segunda necesitaría 200 ms, pero el context expira a los 50 ms (contados desde que se creó), así que devuelve context deadline exceeded.
Nada se detiene a la fuerza. Go no tiene forma de matar una goroutine desde fuera. Un context es una señal, y el código tiene que comprobarla: haciendo select sobre ctx.Done(), comprobando ctx.Err() entre pasos o pasando ctx a llamadas de librería (http.NewRequestWithContext, db.QueryContext, exec.CommandContext) que lo comprueban por ti.
La interfaz Context
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
| Método | Devuelve |
|---|---|
Done() | un channel que se cierra cuando el context se cancela o expira (nil para un context que nunca se puede cancelar) |
Err() | nil mientras está activo, luego context.Canceled o context.DeadlineExceeded |
Deadline() | el deadline y true, o ok == false si no hay |
Value(key) | el valor guardado bajo key en este context o en un ancestro, o nil |
Los contexts son inmutables. Nunca cambias uno; derivas un hijo con una de las funciones With, y el hijo añade una señal de cancelación, un deadline o un valor.
De dónde sale un context
Todo árbol de contexts empieza en una raíz:
context.Background()paramain,init, los tests y la configuración de nivel superior de los servidores.context.TODO()cuando una función debería recibir un context pero quien la llama todavía no tiene uno. Se comporta exactamente igual queBackground; el nombre es una marca para refactorizar más adelante.
Dentro de un handler HTTP no creas una raíz. Usas r.Context(), que el servidor cancela cuando el cliente se desconecta o el handler retorna.
WithCancel: parar cuando quieras
context.WithCancel devuelve un context hijo y una función cancel. Llamar a cancel cierra el channel Done del hijo y los channels Done de todo lo que derive de él.
El envío del productor está en un select junto a ctx.Done(). Eso es lo que le permite parar: un out <- i a secas se bloquearía para siempre en cuanto el consumidor dejara de leer, y la goroutine se fugaría. El for range nums final espera hasta que el productor ha cerrado el channel. Mientras se vacía, el productor todavía puede llegar a enviar un valor o dos, porque cuando los dos casos de su select están listos Go elige uno al azar; la cancelación es rápida, no instantánea.
Se puede llamar a cancel más de una vez y desde cualquier goroutine. Solo la primera llamada hace algo.
WithTimeout y WithDeadline
WithTimeout(parent, d) equivale a WithDeadline(parent, time.Now().Add(d)). Usa un timeout para "como mucho este tiempo" y un deadline cuando tengas una hora absoluta.
Cuando pasa el tiempo, Done se cierra y Err devuelve context.DeadlineExceeded. Si antes se llama a cancel, Err devuelve context.Canceled. Comprueba cuál con errors.Is, porque las librerías suelen envolver el error:
Llama siempre a cancel, incluso con un timeout que saltará solo. El context mantiene un temporizador y un hueco en su padre hasta que ocurre una de las dos cosas, y defer cancel() libera ambos en cuanto la función retorna. go vet informa de una función cancel descartada: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak.
Los hijos no pueden sobrevivir a sus padres
Los contexts forman un árbol. Cancelar un padre cancela a todos sus descendientes. Un hijo puede tener un deadline más corto que su padre, nunca más largo: siempre gana el deadline más temprano.
Esto es lo que hace útiles a los contexts entre capas. Un handler HTTP recibe un context que muere con la petición; una llamada a la base de datos tres capas más abajo deriva de él un timeout de 2 segundos. Si el cliente cuelga a los 100 ms, la consulta se cancela en ese momento, no dos segundos después.
Haz siempre select sobre ctx.Done() cuando te bloquees
Cualquier goroutine que espera (un envío a un channel, una recepción, un temporizador) debería esperar a la vez en ctx.Done(). En bucles de cómputo que nunca se bloquean, comprueba ctx.Err() de vez en cuando:
for i, item := range items {
if i%1000 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
process(item)
}
Usa time.After dentro de un select para una espera sencilla, pero prefiere un temporizador que puedas detener (o un timeout de context) cuando la espera pueda cancelarse a menudo.
Causas de cancelación (Go 1.20 y 1.21)
ctx.Err() solo dice canceled o deadline exceeded. Para registrar el motivo, usa las variantes Cause:
WithCancelCause llegó en Go 1.20, y WithTimeoutCause y WithDeadlineCause en Go 1.21. Err sigue devolviendo los valores estándar para que las comprobaciones existentes sigan funcionando; context.Cause da el detalle.
WithValue, con moderación
context.WithValue(parent, key, value) adjunta un valor. ctx.Value(key) lo busca subiendo por la cadena de padres.
Reglas para los valores:
- Usa un tipo no exportado para las claves, nunca un
stringa secas. Dos paquetes que usen"user"se sobrescribirían. (go vetno lo detecta;staticchecksí.) - Envuelve el acceso en funciones auxiliares tipadas como
WithRequestIDyRequestID, para que quien llama nunca veaanyni la clave. - Guarda solo datos de la petición que atraviesan APIs: IDs de traza y de petición, el usuario autenticado, un logger. Nunca parámetros opcionales, conexiones a la base de datos ni configuración. Eso va en argumentos de función o campos de struct, donde el compilador puede comprobarlo y quien lee el código puede verlo.
- La búsqueda recorre la cadena de padre en padre, así que cada valor que añades alarga un paso la búsqueda de los demás.
Convenciones
ctx context.Contextes el primer parámetro de cualquier función que hace I/O, se bloquea o llama a algo que lo hace:func Fetch(ctx context.Context, url string) error.- No guardes un context en un struct. Pásalo en cada llamada a método. Un context pertenece a una operación, y un struct suele vivir más que ella. (La excepción es un tipo que representa una sola operación, como
http.Request.) - Nunca pases
nilcomo context. Usacontext.TODO()si no tienes nada mejor. - Devuelve
ctx.Err(), o envuélvelo con%w, cuando pares por el context, para que quien llama pueda distinguir un timeout de un fallo real.
Context en servidores y clientes HTTP
En el servidor: r.Context() se cancela cuando el cliente se desconecta, cuando el handler retorna o cuando se reinicia un stream HTTP/2. En el cliente: http.NewRequestWithContext hace que la petición respete un timeout o una cancelación. Este programa ejecuta los dos extremos mediante httptest:
El cliente se rinde a los 50 ms y cierra la conexión. El servidor lo nota, su context de petición se cancela y el handler se detiene en lugar de dedicar 450 milisegundos más a un informe que nadie va a leer. En un handler real pasas r.Context() a cada llamada a la base de datos y HTTP, y todas se detienen a la vez.
Otras funciones auxiliares (Go 1.21)
context.WithoutCancel(ctx)devuelve un context con los mismos valores que no se cancela cuando se cancelactx. Úsalo para trabajo que debe terminar después de que acabe la petición, como escribir un log de auditoría.context.AfterFunc(ctx, f)ejecutafen su propia goroutine cuandoctxtermina, y devuelve una funciónstoppara anular el registro.
Errores comunes
- No llamar a
cancel. Haz siempredefer cancel()justo después deWithCancel,WithTimeoutoWithDeadline. - Lanzar una goroutine que ignora
ctx. Si se bloquea sin hacer select sobrectx.Done(), cancelar no hace nada y la goroutine se fuga. - Crear un
context.Background()nuevo en lo profundo de una cadena de llamadas. Corta el vínculo con el deadline y la cancelación de quien llama. Pasa elctxque recibiste. - Comparar errores con
==. Usaerrors.Is(err, context.DeadlineExceeded); la mayoría de librerías lo envuelven. - Usar
WithValuepara dependencias. Una conexión a la base de datos escondida en un context es un parámetro que el compilador ya no puede comprobar. - Esperar que la cancelación sea instantánea. El código solo se entera en su siguiente comprobación. Un bucle largo sin comprobación sigue ejecutándose.
Preguntas frecuentes
¿Para qué sirve context en Go?
Un context.Context le dice a una función, y a todo lo que esta llama, cuándo rendirse: porque quien llama canceló, porque pasó un deadline o porque el cliente se desconectó. También puede transportar valores asociados a la petición, como un ID de petición. Por convención es el primer parámetro y se llama ctx.
¿Qué diferencia hay entre context.Background y context.TODO?
Las dos devuelven un context vacío que nunca se cancela y no tiene deadline ni valores. Se comportan igual. Background() es la raíz para main, los tests y la configuración de nivel superior. TODO() marca un sitio donde debería pasarse un context real pero el código de alrededor todavía no tiene uno, lo que facilita encontrarlo después.
¿Por qué tengo que llamar a cancel después de context.WithTimeout?
WithTimeout, WithDeadline y WithCancel registran el nuevo context en su padre y pueden arrancar un temporizador. Llamar a cancel libera esos recursos en cuanto terminas, en lugar de cuando salta el timeout o se cancela el padre. Escribe defer cancel() justo después de crearlo; go vet avisa cuando se descarta una función cancel.
¿Qué significa "context deadline exceeded" en Go?
Es el texto de context.DeadlineExceeded, el error que devuelve ctx.Err() una vez que ha pasado el deadline de un context. Las funciones que respetan el context, como los clientes HTTP y los drivers de base de datos, lo devuelven (a menudo envuelto) cuando se quedan sin tiempo. Compruébalo con errors.Is(err, context.DeadlineExceeded).
¿Debo usar context.WithValue para pasar parámetros?
No. Úsalo solo para datos de la petición que atraviesan límites de API y que las funciones intermedias no necesitan conocer, como un ID de traza o el usuario autenticado. Todo lo que una función necesita para hacer su trabajo va en sus parámetros, donde el compilador lo comprueba.