Un goroutine leak es una fuga de recursos de ejecución que ocurre cuando una goroutine entra en un estado de bloqueo indefinido, quedando permanentemente en el scheduler sin posibilidad de alcanzar su instrucción de retorno. No se trata solo de un “proceso que no termina”; es un problema de gestión de memoria crítico. Cada goroutine consume al menos 2KB de su propio stack, pero el daño real reside en que una goroutine bloqueada actúa como una raíz de alcanzabilidad (reachability root) para el Garbage Collector (GC). Esto significa que cualquier objeto en el heap referenciado por esa goroutine o por las variables dentro de su stack no podrá ser liberado, provocando un aumento progresivo de la memoria residente (RSS) y degradando el rendimiento del GC al aumentar el tamaño del grafo de objetos a escanear.
Esto sucede principalmente cuando una goroutine espera un dato en un channel que nunca se cerrará o no recibirá más mensajes, o cuando se queda bloqueada esperando un mutex que nunca se libera. En sistemas de alta carga, si lanzas goroutines por cada petición HTTP sin un mecanismo de cancelación, el servidor eventualmente colapsará por falta de memoria (OOM) o saturación del runtime.
Para evitarlo, cada goroutine debe tener un ciclo de vida claramente definido. La herramienta estándar para lograr esto es el uso de context.Context, que permite propagar señales de cancelación desde el proceso padre hacia todas las goroutines hijas. Si no implementas un mecanismo de salida basado en señales (como un canal de cierre o un contexto), corres el riesgo de dejar procesos “zombis” que consumen recursos sin realizar trabajo útil.
package main
import (
"context"
"fmt"
"runtime"
"time"
)
// workerLeaky representa el error clásico: una goroutine que se queda
// bloqueada esperando datos en un channel sin posibilidad de salir.
func workerLeaky(ch chan int) {
for {
// El bloqueo ocurre aquí. Si ch nunca se cierra o no recibe
// más datos, esta goroutine nunca termina su ejecución.
val := <-ch
fmt.Printf("[Leaky] Procesado: %d\n", val)
}
}
// workerFixed utiliza context.Context para asegurar que la goroutine
// se cierre limpiamente cuando el proceso principal lo requiera.
func workerFixed(ctx context.Context, ch chan int) {
for {
select {
case <-ctx.Done():
// El contexto se ha cancelado; salimos de la goroutine.
return
case val := <-ch:
fmt.Printf("[Fixed] Procesado: %d\n", val)
}
}
}
func main() {
// Registramos la cantidad de goroutines al inicio.
initialGoroutines := runtime.NumGoroutine()
fmt.Printf("Goroutines iniciales: %d\n", initialGoroutines)
// --- Escenario de Leak ---
// Creamos un canal y lanzamos goroutines que se quedarán bloqueadas.
chLeaky := make(chan int)
for i := 0; i < 5; i++ {
go workerLeaky(chLeaky)
}
// --- Escenario Correcto ---
// Usamos un contexto con cancelación para las goroutines bien implementadas.
chFixed := make(chan int)
ctx, cancel := context.WithCancel(context.Background())
for i := 0; i < 5; i++ {
go workerFixed(ctx, chFixed)
}
// Damos un breve respiro para que el scheduler asigne tiempo a las goroutines.
time.Sleep(50 * time.Millisecond)
// Enviamos datos para demostrar que ambas funcionan inicialmente.
chLeaky <- 100
chFixed <- 200
// Cancelamos el contexto para detener las goroutines del escenario "Fixed".
cancel()
// Esperamos a que el scheduler termine las goroutines limpias.
time.Sleep(50 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
fmt.Printf("Goroutines finales: %d\n", finalGoroutines)
fmt.Printf("Diferencia (Leaks detectados): %d\n", finalGoroutines-initialGoroutines)
if finalGoroutines > initialGoroutines+2 { // Margen para goroutines del runtime
fmt.Println("¡ALERTA: Se han detectado goroutines huérfanas!")
}
}
Análisis del código
En el ejemplo, hemos comparado dos formas de manejar el ciclo de vida de una tarea asíncrona.
La función workerLeaky es el culpable del leak. Al realizar una operación de recepción val := <-ch sin estar dentro de un bloque select que considere una señal de salida, la goroutine entra en un estado de espera indefinida si nadie escribe en ch o si ch nunca se cierra. Como la goroutine está bloqueada en esa línea, el scheduler de Go la mantiene en el estado waiting, y su stack permanece ocupado en memoria.
Por el contrario, workerFixed implementa el patrón de diseño recomendado. Al usar un select, la goroutine puede escuchar simultáneamente dos canales: el de datos (ch) y el de cancelación (ctx.Done()). En cuanto llamamos a cancel(), el canal ctx.Done() se cierra, el caso case <-ctx.Done(): se vuelve seleccionable y la goroutine ejecuta el return, liberando su stack y permitiendo que el GC recoja cualquier objeto que estuviera en su alcance.
Para la detección, hemos utilizado runtime.NumGoroutine(). En un entorno de producción o en tests unitarios complejos, es vital comparar este valor antes y después de una operación crítica. Si el número de goroutines no vuelve a su estado inicial tras finalizar una tarea, tienes un leak.
El error frecuente
Un error extremadamente común ocurre cuando intentas enviar datos a un canal desde una goroutine sin asegurar que habrá un receptor, o sin un mecanismo de tiempo de espera.
// Código con leak garantizado
func leakOnSend(ch chan int) {
// Si el receptor de 'ch' deja de escuchar (por un timeout o error),
// esta goroutine se queda bloqueada para siempre en la línea de abajo.
ch <- 42
}
func main() {
ch := make(chan int) // Canal sin buffer
go leakOnSend(ch)
// El programa continúa, pero la goroutine de leakOnSend
// queda bloqueada permanentemente en el envío.
time.Sleep(time.Second)
}
Si el canal ch no tiene buffer (como en el ejemplo) y el hilo principal deja de leerlo, la goroutine que intenta escribir se queda bloqueada indefinidamente. Para evitar esto, siempre que lances una goroutine que escriba en un canal, debes garantizar que el canal sea cerrado o que la goroutine tenga un select con un case <-ctx.Done(): para abandonar el intento de envío.
N° 131