Goroutine Leak: El Error Silencioso que Consume RAM en Golang
1. Descripción General
Las goroutines son lo que hace que Go sea “genial” — ligeras, fáciles de crear, solo unos pocos KB cada una. Pero precisamente por ser ligeras, es fácil crearlas y olvidar… limpiar. Una goroutine leak es cuando una goroutine se bloquea indefinidamente, nunca termina, y se acumula con el tiempo hasta que se agota la RAM o el sistema colapsa.
Después de leer este artículo sabrás: los patrones más comunes que causan leaks, cómo detectarlos usando pprof y métricas de runtime, y cómo solucionarlos con context, canales con buffer y select con timeout.
2. ¿Qué es una goroutine leak?
A diferencia de una memory leak típica (donde un objeto no se recolecta porque aún hay referencias), una goroutine leak es cuando una goroutine se bloquea indefinidamente en algún punto — generalmente esperando leer/escribir en un canal que nunca ocurre. Esa goroutine no muere, no es recolectada, y su stack (junto con todas las variables closure que mantiene) existe indefinidamente en el heap.
El problema más peligroso: el programa no cae inmediatamente. Funciona normalmente, las solicitudes siguen devolviendo resultados correctos, pero el RSS (memoria residente) aumenta gradualmente con cada solicitud. Después de unas pocas horas o días, el contenedor es eliminado por OOMKilled.
3. Patrón que causa leak #1: Enviar a un canal que nadie lee
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string)
go func() {
result := slowHTTPCall(url) // asume que tarda 5s
resultCh <- result // (1) enviar al canal sin buffer
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Si timeout (por ejemplo 1s) es más pequeño que el tiempo que tarda slowHTTPCall en ejecutarse (5s), la función fetchWithTimeout devuelve un error de timeout y se sale inmediatamente en la rama case <-time.After. Pero la goroutine secundaria en la línea (1) sigue ejecutándose. Cuando termina después de 5s, intenta enviar result a resultCh — nadie está leyendo este canal porque la función padre ya retornó. Como resultCh no tiene buffer, enviar a un canal que nadie lee se bloqueará indefinidamente. Esa goroutine fuga indefinidamente.
Si llamas a esta función 1000 veces/segundo con timeout corto, tendrás 1000 goroutine leaks cada segundo.
3.1. Solución: canal con buffer
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string, 1) // (1) buffer size 1
go func() {
result := slowHTTPCall(url)
resultCh <- result // nunca se bloqueará porque ya tiene buffer
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
La línea (1) asigna buffer 1 al canal. Cuando la goroutine secundaria envía result, escribe en el buffer y sale de inmediato — no necesita que nadie lo lea. La goroutine termina normalmente, sin fuga. Esta es una solución rápida pero no definitiva: slowHTTPCall sigue ejecutándose los 5s completos aunque el llamador ya no le importa el resultado, desperdiciando CPU/red.
4. Patrón leak #2: Olvidar cancelar una goroutine cuando el context es cancelado
func worker(ch <-chan int) {
go func() {
for v := range ch { // (1) esperar indefinidamente si ch no se cierra
process(v)
}
}()
}
Si nadie ejecuta close(ch), la goroutine en la línea (1) esperará leer del canal indefinidamente. Este patrón ocurre comúnmente cuando un worker se crea en un alcance de solicitud pero el canal fuente está vinculado a otro ciclo de vida (por ejemplo, una cola global que nunca se cierra).
4.1. Solución con context.Context
func worker(ctx context.Context, ch <-chan int) {
go func() {
for {
select {
case <-ctx.Done(): // (1) recibir señal de cancelación
return
case v, ok := <-ch:
if !ok { // (2) canal está cerrado
return
}
process(v)
}
}
}()
}
La línea (1) escucha en ctx.Done() — cuando el llamador invoca cancel(), la goroutine sale de inmediato incluso si ch no tiene nada para leer. La línea (2) maneja el caso donde el canal se cierra normalmente. Este es el patrón estándar: toda goroutine que se ejecuta durante mucho tiempo debe recibir context.Context y siempre tener un camino de salida a través de ctx.Done().
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
worker(ctx, dataCh)
5. Patrón leak #3: sync.WaitGroup con una goroutine que nunca llamará a Done()
func processAll(items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
if err := validate(it); err != nil {
return // ¿olvidó llamar a Done() antes de return? No, defer sigue ejecutándose
}
save(it)
}(item)
}
wg.Wait() // (1) esperar a que todas las goroutines llamen a Done()
}
En este ejemplo defer wg.Done() es seguro porque defer siempre se ejecuta incluso con un retorno temprano. Pero la fuga real ocurre cuando una goroutine se bloquea internamente antes de poder retornar — por ejemplo, save(it) llama a una API sin timeout y se cuelga indefinidamente. En ese caso wg.Wait() en la línea (1) también se bloquea indefinidamente, arrastrando la goroutine que llama a processAll a un leak también.
Nota:
defer wg.Done()solo te protege del error de olvidar llamar aDone(). No te protege de una goroutine colgada dentro del body. Siempre establece timeout para todo I/O (cliente HTTP, consulta DB, llamada gRPC).
6. Patrón leak #4: Fuga a través de time.Ticker sin ejecutar Stop()
func startMonitor() {
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C { // (1) se ejecuta indefinidamente
checkHealth()
}
}()
}
time.NewTicker crea una goroutine interna en el runtime para bombear ticks en ticker.C. Si nunca llamas ticker.Stop(), tanto la goroutine de lectura en la línea (1) como la goroutine interna del ticker viven indefinidamente — incluso si startMonitor se llama muchas veces (por ejemplo, cada vez que se recarga la configuración), acumularás múltiples tickers ejecutándose en paralelo.
6.1. Solución: vincular el ciclo de vida al context
func startMonitor(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
go func() {
defer ticker.Stop() // (1) siempre limpiar ticker
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkHealth()
}
}
}()
}
La línea (1) garantiza que se llame a ticker.Stop() cuando la goroutine sale, sin importar qué camino tome. ctx.Done() permite que el llamador detenga activamente el monitor cuando ya no sea necesario.
7. Detectar goroutine leaks con pprof
La mejor manera de detectar una fuga no es leyendo código a simple vista sino midiendo directamente.
import (
"net/http"
_ "net/http/pprof" // (1) registra automáticamente las rutas de debug
)
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... resto de la app
}
La línea (1) importa net/http/pprof con nombre vacío (_) — solo necesita el efecto secundario de registrar la ruta /debug/pprof/*, no necesita llamar ninguna función de este paquete.
Ejecuta la app y luego llama:
curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 15234
14998 @ 0x43f1 0x454a 0x4a2b ...
# 0x4a2b main.fetchWithTimeout.func1+0x5b /app/main.go:12
236 @ 0x43f1 0x454c ...
El número 14998 en la primera línea es un indicador claro: casi 15 mil goroutines están colgadas exactamente en un punto del código (main.go:12). Esto es exactamente una goroutine leak — un patrón que se repite en masa, no tráfico normal.
7.1. Monitorear el número de goroutines a lo largo del tiempo
func logGoroutineCount() {
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
log.Printf("goroutines: %d", runtime.NumGoroutine())
}
}
runtime.NumGoroutine() devuelve el número de goroutines en vivo. Si este número aumenta continuamente sin nunca disminuir aunque el tráfico sea estable, es un indicador muy confiable de leak. Deberías exponer esta métrica a través de Prometheus (go_goroutines — en realidad, el cliente Prometheus de Go ya tiene esta métrica lista).
8. Escribir pruebas para atrapar leaks temprano
La librería go.uber.org/goleak ayuda a escribir pruebas que detectan automáticamente leaks en lugar de esperar a detectarlas en producción.
<!-- usa go.mod en lugar de Maven -->
go get go.uber.org/goleak
package fetcher_test
import (
"testing"
"time"
"go.uber.org/goleak"
)
func TestFetchWithTimeout_NoLeak(t *testing.T) {
defer goleak.VerifyNone(t) // (1) verificar después de que la prueba termine
_, err := fetchWithTimeout("http://slow-endpoint.local", 100*time.Millisecond)
if err == nil {
t.Fatal("se esperaba error de timeout")
}
time.Sleep(200 * time.Millisecond) // (2) esperar a que la goroutine secundaria tenga la oportunidad de completarse
}
La línea (1) registra una verificación que se ejecuta después de que termina la prueba: goleak compara la lista de goroutines vivas en ese momento con la línea base, si aún hay goroutines “extrañas” (no del runtime/framework de prueba), la prueba falla e imprime el stack trace del goroutine leak. La línea (2) le da a la goroutine secundaria suficiente tiempo para completarse antes de la verificación — si falta esta línea, la prueba podría producir un falso positivo.
Si usas la versión fetchWithTimeout sin buffer de canal de la sección 3, esta prueba fallará con una salida como:
--- FAIL: TestFetchWithTimeout_NoLeak (0.30s)
goleak.go:39: found unexpected goroutines:
[Goroutine 18 in state chan send, with fetcher.fetchWithTimeout.func1 on top of the stack:
goroutine 18 [chan send]:
fetcher.fetchWithTimeout.func1()
/app/fetcher.go:9 +0x65
]
El stack trace apunta exactamente a la línea que causa el leak — muy útil para depuración.
9. Lista de verificación para evitar goroutine leaks
- Toda goroutine que ejecute I/O largo debe recibir
context.Contexty tener una rama<-ctx.Done(). - Los canales sin buffer usados para enviar resultados al llamador que potencialmente pueden ser ignorados — considera buffer size 1 o usa
context. time.Ticker,time.Timerdeben ejecutarStop()cuando ya no se usen.sync.WaitGroup.Wait()solo es seguro si todas las goroutines secundarias tienen una ruta de salida garantizada (timeout para I/O interno).- Escribe pruebas con
goleak.VerifyNone(t)para funciones que crean goroutines, especialmente funciones con lógica de timeout/cancel. - Monitorea
runtime.NumGoroutine()o la métricago_goroutinesen tu dashboard de producción.
Nota: No toda goroutine que vive mucho tiempo es un leak. Un worker pool que se ejecuta durante toda la vida de la aplicación es un diseño válido. El problema solo surge cuando el número de goroutines crece sin límites con el tráfico sin disminuir en consecuencia.
10. Conclusión
Las goroutine leaks son peligrosas porque no causan errores inmediatos — la aplicación sigue devolviendo resultados correctos mientras la RAM aumenta silenciosamente. Las causas raíz son casi siempre: enviar a un canal que nadie lee, o una goroutine sin ruta de salida cuando el llamador se rinde (timeout, cancel). La prevención es simple: siempre pasa context.Context a goroutines que se ejecutan mucho tiempo, usa buffer en canales de resultados, y escribe pruebas con goleak para atrapar errores en la revisión de código en lugar de esperar a que OOMKilled en producción.