Goroutine Leak: O Erro Silencioso que Consome RAM em Golang
1. Visão Geral
Goroutine é o que torna Go “cool” — leve, fácil de criar, apenas alguns KB cada uma. Mas exatamente por ser leve, você acaba criando-as sem se lembrar de… limpá-las. Goroutine leak é quando uma goroutine fica bloqueada permanentemente, nunca termina e vai se acumulando ao longo do tempo até a RAM ser consumida ou o sistema cair.
Depois de ler este artigo, você saberá: os padrões mais comuns que causam leak, como usar pprof e métricas de runtime para detectar, e como corrigir com context, channel com buffer e select com timeout.
2. O que é Goroutine Leak
Diferentemente de um memory leak comum (objeto não é coletado pelo GC porque ainda tem uma referência), goroutine leak é quando uma goroutine fica bloqueada permanentemente em algum ponto — geralmente esperando uma leitura/escrita em um channel que nunca acontece. Essa goroutine não morre, não é coletada pelo GC, e sua stack (junto com todas as variáveis closure que mantém) permanece indefinidamente na heap.
O problema mais perigoso: o programa não falha imediatamente. Ele roda normalmente, as requisições ainda retornam resultados corretos, mas a RSS (resident memory) aumenta gradualmente a cada requisição. Após algumas horas ou dias, o container é morto por OOMKilled.
3. Padrão que causa leak #1: Enviar para um channel que ninguém está recebendo
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string)
go func() {
result := slowHTTPCall(url) // suponha que leve 5s
resultCh <- result // (1) envia para um channel sem buffer
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Se timeout (por exemplo 1s) for menor que o tempo que slowHTTPCall leva (5s), a função fetchWithTimeout retorna um erro de timeout e sai imediatamente da branch case <-time.After. Mas a goroutine filha na linha (1) ainda está rodando. Quando termina após 5s, ela tenta enviar result para resultCh — ninguém mais está lendo este channel porque a função pai já retornou. Já que resultCh não tem buffer, enviar para um channel que ninguém está recebendo vai bloquear indefinidamente. Essa goroutine vaza permanentemente.
Gọi hàm này 1000 lần/giây với timeout ngắn, bạn có 1000 goroutine leak mỗi giây.
Se você chamar essa função 1000 vezes por segundo com timeout curto, você terá 1000 goroutines vazadas a cada segundo.
3.1. Correção: channel com buffer
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
resultCh := make(chan string, 1) // (1) tamanho de buffer 1
go func() {
result := slowHTTPCall(url)
resultCh <- result // nunca bloqueia porque já tem buffer
}()
select {
case res := <-resultCh:
return res, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
A linha (1) fornece um buffer de tamanho 1 para o channel. Quando a goroutine filha envia result, ela escreve no buffer e sai imediatamente — ninguém precisa ler. A goroutine encerra normalmente, sem vazar. Esta é uma correção rápida mas não completa: slowHTTPCall ainda leva 5s mesmo que o chamador não se importe com o resultado, desperdiçando CPU/network.
4. Padrão que causa leak #2: Esquecer de cancelar goroutine quando o context é cancelado
func worker(ch <-chan int) {
go func() {
for v := range ch { // (1) espera indefinidamente se ch não for fechado
process(v)
}
}()
}
Se ninguém executar close(ch), a goroutine na linha (1) espera ler do channel indefinidamente. Este padrão é comum quando um worker é criado em um escopo de requisição, mas o channel de origem tem um ciclo de vida diferente (por exemplo, uma fila global que nunca fecha).
4.1. Correção com context.Context
func worker(ctx context.Context, ch <-chan int) {
go func() {
for {
select {
case <-ctx.Done(): // (1) recebe sinal de cancelamento
return
case v, ok := <-ch:
if !ok { // (2) channel foi fechado
return
}
process(v)
}
}
}()
}
A linha (1) ouve ctx.Done() — quando o chamador chama cancel(), a goroutine sai imediatamente mesmo que ch não tenha nada para ler. A linha (2) trata o caso quando o channel é fechado normalmente. Este é o padrão correto: toda goroutine que roda por muito tempo deve receber um context.Context e sempre ter uma saída via ctx.Done().
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
worker(ctx, dataCh)
5. Padrão que causa leak #3: sync.WaitGroup com goroutine que nunca chama 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 // esqueci de chamar Done() antes de return? Não, defer ainda roda
}
save(it)
}(item)
}
wg.Wait() // (1) espera todas as goroutines chamarem Done()
}
Neste exemplo, defer wg.Done() é seguro porque defer sempre roda mesmo com return antecipado. Mas o leak real ocorre quando uma goroutine bloqueia internamente antes de conseguir retornar — por exemplo, save(it) chama uma API sem timeout e fica travada para sempre. Quando isso acontece, wg.Wait() na linha (1) também fica bloqueado indefinidamente, causando a goroutine que chamou processAll também vazar.
Nota:
defer wg.Done()só te salva de esquecer chamarDone(). Não te salva de uma goroutine ficar travada dentro do corpo. Sempre coloque timeout em toda I/O (cliente HTTP, consulta de banco de dados, chamada gRPC).
6. Padrão que causa leak #4: Vazamento via time.Ticker sem Stop()
func startMonitor() {
ticker := time.NewTicker(5 * time.Second)
go func() {
for range ticker.C { // (1) roda indefinidamente
checkHealth()
}
}()
}
time.NewTicker cria uma goroutine interna no runtime para bombear ticks para ticker.C. Se você nunca chamar ticker.Stop(), tanto a goroutine que lê na linha (1) quanto a goroutine interna do ticker vivem para sempre — especialmente se startMonitor for chamada várias vezes (por exemplo, toda vez que recarrega config), você acumula múltiplos tickers rodando em paralelo.
6.1. Correção: vincular o ciclo de vida ao context
func startMonitor(ctx context.Context) {
ticker := time.NewTicker(5 * time.Second)
go func() {
defer ticker.Stop() // (1) sempre limpar o ticker
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkHealth()
}
}
}()
}
A linha (1) garante que ticker.Stop() é chamado quando a goroutine sai, não importa de qual forma. ctx.Done() permite que o chamador pare ativamente o monitor quando não precisar mais.
7. Detectando goroutine leak com pprof
A melhor forma de detectar leak não é ler o código de olho, mas medir diretamente.
import (
"net/http"
_ "net/http/pprof" // (1) registra automaticamente as rotas de debug
)
func main() {
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... resto do app
}
A linha (1) importa net/http/pprof com nome vazio (_) — apenas para o efeito colateral de registrar a rota /debug/pprof/*, sem precisar chamar nenhuma função deste pacote.
Execute a app e chame:
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 ...
O número 14998 na primeira linha é um sinal claro: quase 15 mil goroutines estão travadas em exatamente um ponto no código (main.go:12). Este é exatamente um goroutine leak — um padrão que se repete em massa, não é tráfego normal.
7.1. Monitorar a contagem de goroutines ao longo do tempo
func logGoroutineCount() {
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
log.Printf("goroutines: %d", runtime.NumGoroutine())
}
}
runtime.NumGoroutine() retorna o número de goroutines vivas. Se este número aumenta continuamente e nunca diminui mesmo com tráfego estável, é um indicador muito confiável de leak. Você deve expor esta métrica via Prometheus (go_goroutines — na verdade o cliente Go Prometheus já tem essa métrica pronta).
8. Escrever testes para pegar leak cedo
A biblioteca go.uber.org/goleak ajuda a escrever testes que detectam automaticamente leak em vez de esperar descobrir em produção.
<!-- use go.mod em lugar do 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) verifica após o teste terminar
_, err := fetchWithTimeout("http://slow-endpoint.local", 100*time.Millisecond)
if err == nil {
t.Fatal("erro de timeout esperado")
}
time.Sleep(200 * time.Millisecond) // (2) dá tempo para a goroutine filha ter a chance de terminar
}
A linha (1) registra uma verificação que roda após o teste terminar: goleak compara a lista de goroutines vivas naquele momento com a baseline, se houver goroutines “estranhas” (não do runtime/test framework), o teste falha e imprime o stack trace do goroutine leak. A linha (2) dá tempo suficiente para a goroutine filha terminar antes de verificar — sem esta linha, o teste pode ter falso positivo.
Se você usar a versão fetchWithTimeout sem buffer de channel da seção 3, este teste falhará com uma saída 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
]
Stack trace aponta exatamente para a linha que causa o leak — muito útil ao debugar.
9. Checklist para evitar goroutine leak
- Toda goroutine que executa I/O longo deve receber um
context.Contexte ter uma branch<-ctx.Done(). - Channels sem buffer usados para enviar resultados de volta ao chamador podem ser abandonados — considere um tamanho de buffer 1 ou use
context. time.Ticker,time.Timerdevem serStop()quando não forem mais usados.sync.WaitGroup.Wait()é seguro apenas se toda goroutine filha tem uma saída garantida (timeout para I/O interna).- Escreva testes com
goleak.VerifyNone(t)para funções que criam goroutines, especialmente funções com lógica de timeout/cancelamento. - Monitore
runtime.NumGoroutine()ou a métricago_goroutinesno dashboard de produção.
Nota: Nem toda goroutine que vive por muito tempo é um leak. Um worker pool que roda por toda a vida da aplicação é um design válido. O problema só surge quando o número de goroutines cresce indefinidamente com o tráfego sem diminuir proporcionalmente.
10. Conclusão
Goroutine leak é perigoso porque não causa falha imediata — a aplicação ainda retorna resultados corretos enquanto a RAM aumenta gradualmente nos bastidores. A causa raiz é quase sempre: enviar para um channel que ninguém está recebendo, ou goroutine que carece de uma saída quando o chamador desiste (timeout, cancelamento). A prevenção é simples: sempre passe context.Context para goroutines que rodam por muito tempo, coloque buffer nos channels de resultado, e escreva testes com goleak para pegar erros no code review em vez de esperar ser morto por OOMKilled em produção.