BG-01 · ONLINE
[ DESCARGAR CV ]
REGISTRO
REG-001
SECCIÓN
S-LNG · LENGUAJES
ARCHIVADO
2026.03.14
CONSULTA
8 MIN
GOCONCURRENCIADEBUGGING

Goroutines que no mueren: anatomía de una fuga

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.

SEGMENTO DE CÓDIGO · GO
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.

SEGMENTO DE CÓDIGO · GO
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.