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 chamar Done(). 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.Context e 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.Timer devem ser Stop() 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étrica go_goroutines no 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.

golanggoroutineconcurrencymemory-leakperformance