El servicio de pagos llevaba tres semanas en producción cuando la gráfica de memoria empezó a subir en línea recta: 40 MB por hora, sin picos de tráfico que lo justificaran. El heap profiler no mostraba nada anormal. Tardé dos días en mirar la métrica correcta.
El síntoma
La pista no estaba en el heap sino en runtime.NumGoroutine(): el proceso había pasado de ~80 goroutines en reposo a más de 19.000. Cada goroutine cuesta unos pocos KB de stack, así que miles de ellas explican el crecimiento que el heap profiler no atribuía a ningún objeto.
El patrón culpable era un clásico: lanzar una goroutine que escribe el resultado en un canal, con un caller que a veces ya no está escuchando.
func (s *Service) Charge(ctx context.Context, req Request) (Result, error) {
ch := make(chan Result) // sin búfer: el envío bloquea
go func() {
res := s.provider.Process(req) // puede tardar segundos
ch <- res // ← si nadie lee, esto bloquea PARA SIEMPRE
}()
select {
case res := <-ch:
return res, nil
case <-ctx.Done(): // timeout: el caller se va…
return Result{}, ctx.Err() // …y la goroutine queda huérfana
}
}La causa
Cuando el contexto expira, la función retorna y nadie vuelve a leer del canal. La goroutine interna termina su trabajo, intenta enviar el resultado y se queda bloqueada en ch <- res indefinidamente. No es un leak de memoria en el sentido clásico: es un leak de goroutines, y el GC no puede hacer nada porque la goroutine sigue "viva".
Bajo tráfico normal con timeouts ocasionales, cada timeout deja una goroutine zombi. Con un proveedor externo degradado (latencias de 8-10 s y nuestro timeout en 3 s), fabricábamos zombis a ritmo industrial.
El patrón que ahora uso
Dos cambios mínimos eliminan la clase entera de errores: canal con búfer de 1 (el envío nunca bloquea aunque nadie lea) y propagar el contexto hacia el trabajo interno para que también pueda abortar.
func (s *Service) Charge(ctx context.Context, req Request) (Result, error) {
ch := make(chan Result, 1) // búfer 1: el envío SIEMPRE completa
go func() {
ch <- s.provider.Process(ctx, req) // el contexto viaja hacia dentro
}()
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return Result{}, ctx.Err() // la goroutine escribirá al búfer y morirá
}
}- Todo canal cuyo lector puede abandonar lleva búfer ≥ 1.
- Toda función que lance goroutines acepta y propaga context.Context.
- Ninguna goroutine se lanza sin saber quién la espera y cómo termina.
Cómo detectarlas antes de producción
Exporto runtime.NumGoroutine() como métrica desde el día uno y le pongo alerta de pendiente, no de valor absoluto: si crece de forma sostenida durante 30 minutos, algo gotea. Para confirmar, el endpoint de pprof con /debug/pprof/goroutine?debug=1 agrupa los stacks: miles de goroutines clavadas en la misma línea de envío a canal son la firma inconfundible.
En tests, uber-go/goleak verifica al final de cada test que no quedó ninguna goroutine viva. Lo añadí al TestMain de cada paquete con I/O concurrente: desde entonces este tipo de fuga no ha vuelto a llegar a producción.