Encontrando e corrigindo um goroutine leak em uma biblioteca Go popular
Como um worker pool que ninguém fechava vazava goroutines a cada chamada — e o que a correção me ensinou sobre ownership de concorrência em código de biblioteca.
Também disponível em English
Há um tempo contribuí com uma correção para a maroto, uma biblioteca Go popular para geração de PDFs: o PR #499 fechava um pool de goroutines que era criado a cada chamada de Generate() e nunca era encerrado. Cada documento gerado deixava para trás um conjunto de worker goroutines estacionadas para sempre.
Este post percorre a anatomia dessa classe de bug: como detectá-la, por que ela acontece com tanta frequência justamente em código de biblioteca, e as regras que passei a seguir quando uma biblioteca minha cria goroutines.
Por que goroutine leaks são traiçoeiros
Um goroutine leak raramente se anuncia. O programa não quebra, as requisições continuam funcionando, e a memória cresce tão devagar que os dashboards ficam verdes por dias. O sintoma costuma aparecer longe da causa:
- O uso de memória sobe linearmente com o tráfego acumulado, não com a carga
runtime.NumGoroutine()só aumenta, nunca diminui- A latência degrada depois que o serviço está rodando há um tempo, porque o scheduler está gerenciando milhares de goroutines estacionadas
No caso da maroto, o leak era proporcional ao uso: cada chamada para gerar um PDF criava um worker pool, e nada nunca mandava esses workers pararem. Um serviço que renderiza dez mil boletos por dia acumularia dezenas de milhares de goroutines bloqueadas em recebimento de canal.
Uma reprodução mínima
O formato do bug é quase sempre o mesmo. Aqui está uma versão simplificada do padrão — um pool que processa jobs através de um canal:
type pool struct {
jobs chan job
}
func newPool(workers int) *pool {
p := &pool{jobs: make(chan job)}
for i := 0; i < workers; i++ {
go func() {
for j := range p.jobs { // bloqueia para sempre se jobs nunca for fechado
j.run()
}
}()
}
return p
}
Cada worker bloqueia em for j := range p.jobs. Esse loop só termina quando o canal é fechado. Se o código dono do pool nunca o fecha, cada worker fica estacionado no receive — invisível, inalcançável e nunca coletado pelo GC, porque uma goroutine estacionada é uma raiz do GC.
Agora imagine newPool sendo chamado dentro de um método público da API:
func (m *Maroto) Generate() (Document, error) {
p := newPool(runtime.NumCPU()) // novo pool a cada chamada
// ... processa as páginas através do pool ...
return doc, nil // o pool sai de escopo, os workers continuam vivos
}
O valor pool é coletado pelo garbage collector. As goroutines, não. Esse é o bug inteiro.
Detectando
Três ferramentas tornam essa classe de leak visível em minutos.
1. Contar goroutines em um teste
O detector mais barato é uma asserção de que chamar a API repetidamente não aumenta a contagem de goroutines:
func TestGenerateDoesNotLeak(t *testing.T) {
before := runtime.NumGoroutine()
for i := 0; i < 10; i++ {
if _, err := m.Generate(); err != nil {
t.Fatal(err)
}
}
runtime.GC()
time.Sleep(100 * time.Millisecond) // deixa goroutines em saída terminarem
if after := runtime.NumGoroutine(); after > before+2 {
t.Fatalf("goroutines cresceram de %d para %d", before, after)
}
}
É rudimentar, mas captura o padrão “leak por chamada” de forma confiável — e roda no CI para sempre.
2. O profile de goroutines do pprof
Em um serviço rodando, go tool pprof http://localhost:6060/debug/pprof/goroutine agrupa goroutines por stack. Um leak é inconfundível: milhares de goroutines estacionadas exatamente na mesma linha de chan receive.
3. goleak
O goleak, da Uber, embala a mesma ideia em defer goleak.VerifyNone(t) e filtra o ruído do runtime para você.
A correção: toda goroutine precisa de um dono
A correção real do PR era conceitualmente uma linha: quando a geração termina, fechar o pool para que os loops range dos workers terminem.
func (m *Maroto) Generate() (Document, error) {
p := newPool(runtime.NumCPU())
defer p.Close() // workers saem quando o canal de jobs é fechado
// ... processa as páginas através do pool ...
return doc, nil
}
Mas a lição duradoura é a regra de design por trás, que a própria documentação de Go sugere e que code review vive reensinando:
Nunca inicie uma goroutine sem saber como ela vai parar.
Para código de biblioteca, eu deixaria a regra mais estrita. Uma biblioteca que cria goroutines deve:
- Amarrar o ciclo de vida a uma chamada — iniciá-las dentro da função e garantir que terminem antes (ou logo depois) do retorno (
defer pool.Close(),errgroup.Wait()), ou - Amarrar o ciclo de vida a um objeto — e dar ao objeto um
Close()/Shutdown(ctx)explícito que o chamador é documentado a invocar, ou - Aceitar um
context.Contexte encerrar quando ele for cancelado.
Qualquer outra coisa significa que o chamador paga por goroutines que não consegue ver nem parar. É exatamente isso que tornou o bug da maroto interessante: nada dentro da biblioteca estava errado do ponto de vista dela — o contrato com o chamador é que estava errado.
Conclusões
- Goroutine leaks são proporcionais ao uso, não à carga. Observe
NumGoroutine()ao longo do tempo, não só a memória. - Uma goroutine estacionada é uma raiz do GC. O objeto do pool ser coletado não te salva.
- Coloque uma asserção de leak na suíte de testes no dia em que introduzir um worker pool — são dez linhas.
- Em APIs de biblioteca, o ciclo de vida da concorrência faz parte do contrato público. Documente quem para o quê — ou melhor, torne impossível errar.
A correção foi lançada na maroto e o leak sumiu. O teste que conta goroutines continua lá, garantindo que continue sumido.