Menu

Context en Golang: cancelación, timeouts y valores

Cómo context.Context transporta cancelación, deadlines y valores de la petición a través de un programa Go: Background, WithCancel, WithTimeout, WithValue, ctx.Done en select y context en servidores y clientes HTTP.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

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étodoDevuelve
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() para main, 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 que Background; 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 string a secas. Dos paquetes que usen "user" se sobrescribirían. (go vet no lo detecta; staticcheck sí.)
  • Envuelve el acceso en funciones auxiliares tipadas como WithRequestID y RequestID, para que quien llama nunca vea any ni 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.Context es 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 nil como context. Usa context.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 cancela ctx. Ú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) ejecuta f en su propia goroutine cuando ctx termina, y devuelve una función stop para anular el registro.

Errores comunes

  • No llamar a cancel. Haz siempre defer cancel() justo después de WithCancel, WithTimeout o WithDeadline.
  • Lanzar una goroutine que ignora ctx. Si se bloquea sin hacer select sobre ctx.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 el ctx que recibiste.
  • Comparar errores con ==. Usa errors.Is(err, context.DeadlineExceeded); la mayoría de librerías lo envuelven.
  • Usar WithValue para 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.

Coddy programming languages illustration

Aprende a programar con Coddy

COMENZAR