En Go, las goroutines no tienen un botón de “pánico” o “kill” que puedas pulsar desde fuera para detenerlas instantáneamente. La gestión de su ciclo de vida es cooperativa. Esto significa que una goroutine debe estar diseñada para escuchar una señal de parada y terminar su ejecución de forma limpia. El patrón para lograr esto es el Supervisor Pattern, donde una goroutine controladora monitoriza la salud de sus subordinadas. Si una worker falla, el supervisor decide si debe reiniciarla o si el error es tan crítico que debe provocar un shutdown en cascada de todo el sistema.
Para implementar esto de forma eficiente, utilizamos context.Context como el mecanismo de comunicación principal. Cuando cancelas un contexto raíz, la señal de cancelación se propaga hacia todos los contextos derivados en una cascada, informando a todas las goroutines que es hora de limpiar sus recursos y salir. Si no implementas esta jerarquía y una goroutine se queda esperando un canal que nunca recibirá un dato, creas una goroutine leak (fuga de goroutines), una de las causas más comunes de consumo de memoria descontrolado y agotamiento de descriptores de archivos en sistemas de alta carga. Para detectar estos errores en tus tests, es imprescindible usar herramientas como goleak.
Cuando ejecutas un sistema complejo, necesitas que el cierre de la aplicación sea graceful (ordenado). No quieres que el proceso muera mientras una goroutine está escribiendo un archivo o procesando una transacción de base de datos. Al usar sync.WaitGroup junto con el context.Context, garantizas que el proceso principal espere a que todas las tareas finalizadas completen su limpieza antes de salir.
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
// WorkerError representa un fallo controlado en una worker.
var WorkerError = errors.New("error crítico en la worker")
// Worker simula una tarea de larga duración que consume recursos.
func Worker(ctx context.Context, id int, errChan chan<- error) {
fmt.Printf("[Worker %d] Iniciada\n", id)
defer fmt.Printf("[Worker %d] Finalizada\n", id)
for {
select {
case <-ctx.Done():
// El contexto se canceló (shutdown en cascada).
fmt.Printf("[Worker %d] Recibido aviso de parada: %v\n", id, ctx.Err())
return
case <-time.After(2 * time.Second):
// Simulamos trabajo periódico.
fmt.Printf("[Worker %d] Procesando datos...\n", id)
// Simulamos un fallo aleatorio para probar el supervisor.
if time.Now().UnixNano()%5 == 0 {
errChan <- fmt.Errorf("%w: fallo en el ciclo de la worker %d", WorkerError, id)
return
}
}
}
}
// Supervisor gestiona el ciclo de vida de las workers.
func Supervisor(ctx context.Context) error {
var wg sync.WaitGroup
errChan := make(chan error, 1)
// Usamos un contexto derivado para poder reiniciar la worker
// sin cancelar todo el sistema si solo queremos reiniciar un componente.
workerCtx, cancelWorker := context.WithCancel(ctx)
defer cancelWorker()
// Lanzamos la primera worker.
wg.Add(1)
go func() {
defer wg.Done()
Worker(workerCtx, 1, errChan)
}()
for {
select {
case <-ctx.Done():
// El contexto principal (del sistema) se ha cancelado.
fmt.Println("[Supervisor] Shutdown global recibido. Esperando workers...")
wg.Wait()
return ctx.Err()
case err := <-errChan:
// Capturamos un error de una worker.
fmt.Printf("[Supervisor] Error detectado: %v\n", err)
if errors.Is(err, WorkerError) {
fmt.Println("[Supervisor] Error recuperable. Reiniciando worker...")
// Para reiniciar, primero cancelamos la ejecución actual de la worker
// y esperamos a que se limpie antes de lanzar una nueva.
cancelWorker()
// Nota: En un sistema real, aquí usarías un WaitGroup específico
// para asegurar que la worker vieja murió antes de lanzar la nueva.
// Para este ejemplo simplificado, reiniciamos el ciclo de la worker.
workerCtx, cancelWorker = context.WithCancel(ctx)
wg.Add(1)
go func() {
defer wg.Done()
Worker(workerCtx, 1, errChan)
}()
} else {
return err
}
}
}
}
func main() {
// signal.NotifyContext [disponible desde Go 1.16]
// Crea un contexto que se cancela cuando se recibe SIGINT o SIGTERM.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
fmt.Println("Presiona Ctrl+C para detener el sistema...")
if err := Supervisor(ctx); err != nil && !errors.Is(err, context.Canceled) {
fmt.Printf("Sistema finalizado con error: %v\n", err)
os.Exit(1)
}
fmt.Println("Sistema cerrado ordenadamente.")
}
Análisis del código
En el ejemplo, el Supervisor es el cerebro. No se limita a lanzar goroutines con go Worker(...), sino que gestiona un canal de errores errChan. Fíjate en la estructura del select dentro de Supervisor: es el motor de decisiones. Si recibe un error del tipo WorkerError, el supervisor tiene el control para decidir si el error es fatal o si debe intentar un reinicio.
Para evitar que los errores se pierdan, el canal errChan debe ser capaz de recibir mensajes sin bloquear la worker; por eso usamos un buffer make(chan error, 1). Cuando la Worker detecta un problema, envía el error al canal y termina su ejecución.
La clave de la cancelación en cascada reside en Worker(ctx, ...). La función no tiene un bucle infinito ciego; en cada iteración, consulta case <-ctx.Done(). Esto es vital: si el main recibe una señal de interrupción (SIGINT), el ctx se cancela, lo que propaga la señal al Supervisor y de este a la Worker. Sin esta comprobación dentro del select de la Worker, la goroutine seguiría ejecutándose en segundo plano incluso después de que main haya terminado, causando un proceso zombie o una fuga de recursos.
El error frecuente
El error más peligroso en el manejo de ciclos de vida es el bloqueo por canal sin receptor.
// ERROR: Esta goroutine nunca morirá si el canal 'dataCh' no es cerrado
// o si nadie está escuchando.
func leakyWorker(dataCh chan int) {
for {
val := <-dataCh // Si el programa principal sigue vivo pero nadie escribe en dataCh...
fmt.Println(val) // ...esta goroutine queda colgada para siempre.
}
}
Si tu lógica de negocio depende de un canal de entrada, la goroutine debe tener un case <-ctx.Done(): return en su select. Si solo escuchas el canal de datos, en cuanto la lógica que escribe en ese canal se detenga, habrás creado una fuga de memoria permanente que solo se liberará cuando el proceso muera por completo.
N° 221