felipe.cooper
Todos os artigos
5 min de leitura

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

Nesta página

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:

  1. 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
  2. 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
  3. Aceitar um context.Context e 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.

Compartilhar