El paquete context es la herramienta estándar en Go para gestionar la señalización de cancelación y la propagación de límites de tiempo a través de las llamadas. En términos prácticos, un Context es un objeto que viaja por tu árbol de llamadas, permitiendo que una función superior le diga a todas sus funciones descendientes (y a las goroutines que estas hayan lanzado) que dejen de trabajar.
Esto funciona mediante un mecanismo de comunicación por canales: cada contexto tiene un canal llamado Done(). Cuando el contexto se cancela, este canal se cierra. En Go, recibir de un canal cerrado devuelve el valor cero inmediatamente, lo que permite que cualquier sentencia select que esté escuchando a ctx.Done() se desbloquee y continúra su ejecución. Debes usar estos mecanismos siempre que realices operaciones bloqueantes, como llamadas de red o lectura de archivos, o cuando lances goroutines para tareas que podrían quedar huérfanas. Si ignoras la señal de cancelación o no implementas el chequeo del contexto, terminarás con “fugas de goroutines” (goroutine leaks), donde procesos zombies siguen consumiendo CPU y memoria en segundo plano aunque su resultado ya no sea necesario.
package main
import (
"context"
"fmt"
"time"
)
// heavyOperation simula una tarea que tarda tiempo y debe ser sensible al contexto.
func heavyOperation(ctx context.Context, name string) error {
fmt.Printf("[%s] Iniciando tarea pesada...\n", name)
// Simulamos un trabajo que tarda 3 segundos.
// Usamos select para escuchar dos canales simultáneamente.
select {
case <-time.After(3 * time.Second):
// Si el temporizador termina primero, la tarea fue exitosa.
fmt.Printf("[%s] ✅ Completada con éxito.\n", name)
return nil
case <-ctx.Done():
// Si el canal Done() se cierra, significa que el contexto fue cancelado
// o el tiempo se agotó. ctx.Err() nos dice la razón (Canceled o DeadlineExceeded).
fmt.Printf("[%s] ❌ Interrumpida: %v\n", name, ctx.Err())
return ctx.Err()
}
}
func main() {
// 1. Escenario con WithCancel: Cancelación manual.
fmt.Println("--- Escenario: Con WithCancel ---")
ctxCancel, cancelManual := context.WithCancel(context.Background())
// Lanzamos la tarea en una goroutine para poder cancelarla manualmente.
go func() {
time.Sleep(1 * time.Second) // Esperamos un poco y cancelamos.
fmt.Println("[Main] Decidimos cancelar la tarea manualmente...")
cancelManual()
}()
_ = heavyOperation(ctxCancel, "Manual")
// 2. Escenario con WithTimeout: Tiempo límite automático.
fmt.Println("\n--- Escenario: Con WithTimeout ---")
// El contexto se cancelará automáticamente después de 2 segundos.
ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 2*time.Second)
// Siempre llamamos a cancel() para liberar recursos del timer, incluso si el timeout ocurre.
defer cancelTimeout()
_ = heavyOperation(ctxTimeout, "Timeout")
// 3. Escenario con WithDeadline: Un momento específico en el tiempo.
fmt.Println("\n--- Escenario: Con WithDeadline ---")
// Definimos un momento exacto en el futuro.
deadline := time.Now().Add(4 * time.Second)
ctxDeadline, cancelDeadline := context.WithDeadline(context.Background(), deadline)
defer cancelDeadline()
_ = heavyOperation(ctxDeadline, "Deadline")
}
Análisis del código
En el ejemplo, hemos unificado tres técnicas de control en una sola lógica de ejecución.
Primero, en el escenario con WithCancel, observamos que cancelManual() se invoca desde una goroutine independiente tras un segundo. Como la función heavyOperation está escuchando el canal ctx.Done() mediante un select, la cancelación interrumpe inmediatamente el bloqueo de time.After, permitiendo que la función termine con el error context.Canceled.
En el segundo caso, WithTimeout actúa de forma similar pero de manera automatizada. Internamente, Go ha programado un temporizador que cerrará el canal Done() al pasar los 2 segundos. Al ser la tarea de 3 segundos, el select captura la señal de tiempo agotado antes de que la operación termine.
Finalmente, WithDeadline funciona igual que el timeout, pero en lugar de una duración (duración relativa), recibe un objeto time.Time (duración absoluta). Es la base sobre la cual se implementa WithTimeout.
Un detalle crítico en todos los casos es el uso de defer cancel(). Aunque el contexto se cancele por un timeout o un deadline, las funciones de cancelación devueltas por WithCancel, WithTimeout y WithDeadline deben llamarse para limpiar los recursos internos (como los timers) que el runtime de Go mantiene en memoria.
El error frecuente
El error más peligroso es olvidar llamar a la función cancel devuelta por WithTimeout o WithDeadline.
// ❌ ERROR: Fuga de recursos
func leakExample() {
// El contexto tiene un timeout de 10 minutos, pero la función termina en 1 segundo.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
// Olvidamos el defer cancel()
doWork(ctx)
return // La función termina, pero el timer del contexto sigue vivo en el runtime
}
Si no llamas a cancel(), el objeto context y su temporizador asociado permanecerán en memoria hasta que el tiempo de 10 minutos se cumpla, incluso si tu función principal ya terminó. En servicios de alta carga, esto se traduce en un aumento constante de la memoria y el uso de CPU por la gestión de timers huérfanos.
N° 125