En Go, el context.Context no es un interruptor de emergencia que apaga goroutines a la fuerza. Es un mecanismo de señalización. La cancelación es cooperativa: esto significa que el runtime de Go no puede entrar en una goroutine y detenerla arbitrariamente (porque eso dejaría recursos como mutexes bloqueados o archivos abiertos en un estado inconsistente); es la propia goroutine la que debe estar “escuchando” activamente la señal de cancelación para detenerse voluntariamente.
Para que esto funcione, debes usar el patrón select con el canal ctx.Done() en cualquier operación que pueda bloquearse, como llamadas a bases de datos, peticiones HTTP o bucles de cálculo intensivo. Si ignoras el contexto en una función que realiza trabajo pesado, creas un goroutine leak: una goroutine que sigue consumiendo CPU y memoria en segundo plano para siempre, incluso si el cliente que la invocó ya se ha desconectado o el proceso ya no la necesita.
Debes usar este patrón siempre que invoques funciones de la librería estándar que aceptan contexto, como db.QueryContext o http.NewRequestWithContext, o cuando diseñes tus propias funciones de larga duración. Al revisar el estado del contexto, puedes usar ctx.Err() para distinguir si la operación se detuvo por un error real o porque se alcanzó un tiempo límite (context.DeadlineExceeded) o porque se canceló manualmente (context.Canceled).
package main
import (
"context"
"errors"
"fmt"
"time"
)
// SimulateDBQuery simula una consulta a una base de datos que tarda 2 segundos.
// Es cooperativa porque escucha el canal Done().
func SimulateDBQuery(ctx context.Context) (string, error) {
// Simulamos que la base de datos está procesando la consulta.
workDone := make(chan string)
go func() {
time.Sleep(2 * time.Second)
workDone <- "datos_del_usuario_123"
}()
// El select es la clave de la cancelación cooperativa.
select {
case <-ctx.Done():
// Si el contexto se cancela, salimos inmediatamente.
// Retornamos ctx.Err() para que el llamante sepa por qué falló.
return "", ctx.Err()
case res := <-workDone:
// Si el trabajo termina antes que el contexto, devolvemos el resultado.
return res, nil
}
}
func RunService(ctx context.Context) error {
fmt.Println("Iniciando operación de servicio...")
// Propagamos el contexto a la función que hace la lógica pesada.
data, err := SimulateDBQuery(ctx)
if err != nil {
return fmt.Errorf("error en el servicio: %w", err)
}
fmt.Printf("Servicio completado con éxito: %s\n", data)
return nil
}
func main() {
// Escenario 1: La operación es más rápida que el timeout.
fmt.Println("--- Escenario 1: Éxito ---")
ctxOk, cancelOk := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelOk() // Importante para liberar recursos de memoria.
if err := RunService(ctxOk); err != nil {
fmt.Printf("Fallo inesperado: %v\n", err)
}
// Escenario 2: El timeout es menor que la duración de la tarea.
fmt.Println("\n--- Escenario 2: Timeout (DeadlineExceeded) ---")
ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 1*time.Second)
defer cancelTimeout()
if err := RunService(ctxTimeout); err != nil {
// Aquí detectamos que el error es por el tiempo excedido.
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("La operación se detuvo porque el tiempo se agotó.")
} else {
fmt.Printf("Error: %v\n", err)
}
}
// Escenario 3: Cancelación manual explícita.
fmt.Println("\n--- Escenario 3: Cancelación Manual ---")
ctxCancel, cancelNow := context.WithCancel(context.Background())
// Lanzamos la tarea en una goroutine para poder cancelarla en cualquier momento.
go func() {
time.Sleep(500 * time.Millisecond)
fmt.Println("[Sistema] Cancelando operación manualmente...")
cancelNow()
}()
if err := RunService(ctxCancel); err != nil {
if errors.Is(err, context.Canceled) {
fmt.Println("La operación fue abortada por el usuario.")
} else {
fmt.Printf("Error: %v\n", err)
}
}
}
Análisis del código
En el SimulateDBQuery, el uso del select es fundamental. Si solo usáramos la variable workDone, la función se quedaría bloqueada esperando los 2 segundos de time.Sleep, incluso si el main ya hubiera cancelado el contexto. Al incluir case <-ctx.Done():, permitimos que la función “se rinda” en cuanto el canal de cancelación se cierra.
En el main, observa cómo RunService recibe el contexto y lo propaga. Esta propagación es lo que permite que una cancelación en la capa superior (como un timeout de un servidor HTTP) se filtre hacia abajo, llegando hasta la consulta de la base de datos o la llamada de red.
Cuando ocurre una cancelación, ctx.Err() devuelve un valor específico. En el Escenario 2, al expirar el tiempo, recibimos context.DeadlineExceeded. En el Escenario 3, al llamar a cancelNow(), recibimos context.Canceled. Esto es vital para la observabilidad: no es lo mismo que una base de datos falle (un error de red) a que un cliente haya desconectado la petición (un timeout).
El uso de defer cancel() es una regla de oro. Cada vez que creas un contexto con WithCancel, WithDeadline o WithTimeout, el runtime reserva recursos internos para manejar el temporizador o el canal. Si no llamas a cancel(), esos recursos solo se liberarán cuando el tiempo se cumpla o el programa termine, lo que puede causar fugas de memoria en aplicaciones de larga duración.
El error frecuente
El error más peligroso es la omisión de la comprobación del contexto en bucles o procesos bloqueantes.
// ERROR: Esto causa una goroutine leak
func LeakFunction(ctx context.Context) {
// Supongamos que esto se lanza en una goroutine para cada request
go func() {
for {
// Esta operación es lenta y no escucha el contexto
doHeavyWork()
// Si el cliente cancela la petición, este bucle
// seguirá corriendo infinitamente en segundo plano.
time.Sleep(time.Second)
}
}()
}
Si doHeavyWork no es “context-aware” (es decir, no acepta un ctx o no tiene un select interno), la goroutine se vuelve huérfana. En un servidor de alto tráfico, si los clientes cancelan sus peticiones frecuentemente pero tus goroutines siguen trabajando, tu uso de memoria y CPU subirá linealmente hasta que el sistema colapse por falta de recursos.
N° 126