Detecção de Vazamento de Goroutines: o Perfil goroutineleak e o goleak
Objective
Um vazamento de goroutine é uma goroutine bloqueada para sempre num channel ou numa primitiva de
sync que nada pode desbloquear; ela prende a pilha e tudo que a pilha referencia, e o serviço
cresce até ser reiniciado. O Go 1.27 torna o perfil goroutineleak disponível para todos: sob
demanda, o GC prova quais goroutines bloqueadas nunca vão acordar, e o runtime/pprof as reporta.
Esta lição vaza goroutines de propósito, lê o perfil, explica o algoritmo de alcançabilidade
(Saioc et al., ASPLOS 2025), mostra os pontos cegos e um falso negativo que a documentação não
cita, compara com o perfil de goroutines e com o go.uber.org/goleak e mede o custo. Medido com
go1.27.0 darwin/arm64 num Apple M4 Max (14 núcleos, macOS) e goleak v1.3.0; toda saída vem
de 3 ou mais execuções e só se afirma o que foi estável.
Use Cases
- Provar que a contagem de goroutines subindo é vazamento, não pico de tráfego, com a linha exata.
- Exportar um perfil de vazamento periódico ao lado dos perfis de CPU e heap que o serviço já tem.
- Pegar vazamentos no CI com
goleake saber quais só o goleak enxerga. - Auditar código de fan-out: retorno antecipado em erro,
selectcom timeout,Stopesquecido.
Deep Dive
Um vazamento que o GC consegue provar
O formato clássico: resultados num channel sem buffer, e o coletor retorna no primeiro erro.
gofunc processAll(items []int) ([]int, error) {
ch := make(chan result) // sem buffer; result é struct{ n int; err error }
for _, it := range items {
go func() {
n, err := work(it) // work(3) falha na hora, as outras dormem 1 ms
ch <- result{n, err}
}()
}
var out []int
for range items {
r := <-ch
if r.err != nil {
return nil, r.err // os outros remetentes ficam bloqueados para sempre
}
out = append(out, r.n)
}
return out, nil
}O main inicia 4 workers de pool lendo um channel jobs que ele ainda segura, chama processAll
10 vezes com 10 itens e depois pprof.Lookup("goroutineleak").WriteTo(f, 0) (um GC de detecção):
textNumGoroutine: 95 goroutine profile count: 95 goroutineleak profile count: 90 NumGoroutine after detection: 95
90 são 10 chamadas vezes 9 remetentes presos. Os 4 workers do pool também estão bloqueados, mas
num channel que uma goroutine em execução ainda alcança. NumGoroutine continua 95: o runtime
reporta e não libera nada (o paper do Golf também descreve recuperação; o perfil só reporta).
text$ curl -s 'localhost:6060/debug/pprof/goroutineleak?debug=1' goroutineleak profile: total 144 144 @ 0x104ec0688 0x104e518a4 0x104e514b8 0x10506bc68 0x104ec76a4 # 0x10506bc67 main.processAll.func1+0x97 ./main.go:30
Essa é uma variante servidor que chama processAll em loop com import _ "net/http/pprof" (sem
flag no 1.27); go tool pprof -list processAll.func1 põe todas na linha 30. debug=2 despeja
todas as goroutines no formato de panic, e as vazadas ganham um status novo: goroutine 39 [chan send (leaked)]:. Medido: só escrever o perfil dispara a detecção, e Profile.Count() devolve o
resultado do último ciclo (0 antes do primeiro), então o índice /debug/pprof/ pode estar velho.
Como a detecção funciona
Pelo design doc da proposta (74609) e pelo runtime/mgc.go do go1.27.0:
- A marcação parte das globais e só das goroutines que ainda podem rodar, não de toda pilha.
- Os ponteiros do
sudogde uma goroutine bloqueada para o seu channel ou objeto desyncficam não rastreáveis no ciclo, para a goroutine não manter vivo o próprio channel. - Quando a marcação termina, cada goroutine bloqueada cuja primitiva foi marcada vira raiz e a marcação recomeça, até nada novo ser promovido.
- As restantes passam para
_Gleakede são reportadas; depois as pilhas delas são marcadas, e o ciclo não libera nada que elas referenciam.
A checagem por goroutine (isMaybeRunnable) trata select {} e operações em channel nil como
nunca executáveis, confere esperas em channel, select, Cond, WaitGroup, Mutex e RWMutex
contra os bits de marcação, e devolve "talvez executável" para todo o resto (sleep, espera de
I/O). O design doc afirma que a abordagem é correta ("there are no false positives"): uma
primitiva que nenhum código executável alcança nunca mais pode ser operada.
Ponto cego: uma global ou uma goroutine viva ainda alcança a primitiva
As release notes avisam que vazamentos em primitivas alcançáveis "through global variables or the local variables of runnable goroutines" podem passar. Os mesmos 10 remetentes presos, de três jeitos:
goch := make(chan int)
for i := range 10 {
go func(c chan int, v int) { c <- v }(ch, i) // ch por valor: o ch = nil abaixo não interfere
}
switch mode {
case "global":
lastCh = ch // uma var chan int no nível do pacote
case "runnable": // um monitor que dorme em loop, segurando ch numa variável local
go func(c chan int) {
for {
time.Sleep(time.Second)
_ = len(c)
}
}(ch)
}
ch = niltextlocal NumGoroutine=11 goroutineleak=10 global NumGoroutine=11 goroutineleak=0 runnable NumGoroutine=12 goroutineleak=0
Os remetentes estão igualmente presos nos três casos; só a alcançabilidade mudou. Goroutine que
dorme conta como executável, então qualquer goroutine longeva com uma referência esconde o
vazamento. Capturar ch (go func(v int) { ch <- v }(i)) estraga a medida: quem não rodou antes de
ch = nil envia num channel nil, vazado por definição, e global e runnable deram 6 a 10.
Um falso negativo que a documentação não cita
Montando estes exemplos, vazamentos escritos como go func() { ch <- i }() nunca apareciam.
Quatro jeitos de enviar a partir de 10 goroutines que vazam todas:
gogo func() { ch <- i }() // captured: newly leaked 0
go func() { ch <- i + 1 }() // computed: newly leaked 10
go func(v int) { ch <- v }(i) // param: newly leaked 10
go func() { ch <- 42 }() // constant: newly leaked 10O assembly arm64 da closure captured (go build -gcflags=-S) explica:
textmain.captured.func1: ADD $16, R26, R1 // elem = &closure.i (R26 é o contexto da closure) MOVD 8(R26), R0 // ch, lido do mesmo objeto closure CALL runtime.chansend1(SB)
Um envio passa o valor por endereço; aqui ele aponta para dentro da closure no heap, que também
guarda ch. Em esperas de channel o detector esconde sudog.c, mas deixa sudog.elem
rastreável, então o GC percorre goroutine, sudog, closure, channel e marca o channel. As outras
variantes enviam de um temporário na pilha. Medido no go1.27.0 darwin/arm64, ausente do blog, das
release notes e do design doc: perfil vazio é "nenhum vazamento provado", nunca "nenhum vazamento".
Perfil de goroutines versus perfil de vazamento
O perfil goroutine comum do primeiro programa:
textgoroutine profile: total 95 90 @ 0x104c13f48 0x104bae224 0x104bade38 0x104c68788 0x104c1a054 # 0x104c68787 main.processAll.func1+0x97 ./main.go:30 4 @ 0x104c13f48 0x104baf0e0 0x104baec54 0x104c687d4 0x104c1a054 # 0x104c687d3 main.startPool.func1+0x33 ./main.go:47
Os dois grupos estão parados em operações de channel e nada diz qual é o bug: o blog chama o perfil de goroutines de "a rudimentary way", que não separa vazamento de bloqueio proposital. O LeakProf da Uber (CGO 2024) minerou esses perfis com heurísticas e ainda achou 24 vazamentos em produção (21 corrigidos, até 34% de ganho de velocidade e 9,2x menos memória em alguns serviços).
goleak nos testes
O goleak lista todas as goroutines no fim do teste e falha se sobrar alguma inesperada:
gofunc TestLeaky(t *testing.T) {
defer goleak.VerifyNone(t)
ProcessAll(items, false) // TestFixed passa true: make(chan result, len(items))
}
var events = make(chan string) // nível do pacote
func StartAudit() { go func() { for range events {} }() }
func TestAudit(t *testing.T) {
defer goleak.VerifyNone(t)
StartAudit()
}text--- FAIL: TestLeaky (0.44s) work_test.go:18: found unexpected goroutines: [Goroutine 8 in state chan send, with example.com/goleaktest.ProcessAll.func1 on top of the stack: Goroutine 9 in state chan send, with example.com/goleaktest.ProcessAll.func1 on top of the stack: --- PASS: TestFixed (0.00s) $ go test -run '^TestAudit$' . --- FAIL: TestAudit (0.45s) work_test.go:32: found unexpected goroutines: [Goroutine 6 in state chan receive, with example.com/goleaktest.StartAudit.func1 on top of the stack:
As pilhas foram cortadas: o goleak v1.3.0 lista cada um dos 9 remetentes com a própria pilha.
O TestFixed também passa goleak.IgnoreCurrent(); sem isso ele falhava, porque o VerifyNone
enxerga o processo inteiro, inclusive as 9 goroutines que o TestLeaky deixou. Os 0,44 s são o
goleak tentando de novo (20 tentativas, espera de até 100 ms). Depois de ProcessAll mais
StartAudit, o perfil do runtime reportou goroutineleak count = 9: o consumidor de auditoria
espera num channel global, então só o goleak o aponta. Segundo o paper do CGO 2024, o goleak achou
857 vazamentos antigos num monorepo de 75 milhões de linhas e evitou cerca de 260 novos em um ano.
Quanto custa um ciclo de detecção
Vazamentos comuns (n goroutines vazadas em channels separados, 5 execuções) e o pior caso "daisy chain" do blog (a goroutine i espera no channel i e segura o channel i+1, cabeça numa global, nada vazado, então cada rodada do passo 3 promove uma goroutine; 3 execuções):
textn=10000 runtime.GC 2.5 to 3.5 ms goroutineleak 7.8 to 8.3 ms goroutine 4.8 to 5.0 ms n=100000 runtime.GC 25 to 26 ms goroutineleak 80 to 82 ms goroutine 51 to 52 ms chain n=1000 runtime.GC ~0.4 ms goroutineleak 30 to 34 ms chain n=4000 runtime.GC ~2 ms goroutineleak 155 to 169 ms chain n=16000 runtime.GC 4 to 6 ms goroutineleak 1.31 to 1.36 s
O runtime/metrics contou 16003 pausas stop-the-world no ciclo de 16000 (uma por rodada), nenhuma
num bucket acima de 0,92 ms; o GODEBUG=gctrace=1 marca o ciclo com (checking for goroutine leaks).
Trade-offs
- Correto, não completo: sem falsos positivos por construção, mas vazamento em algo ainda
alcançável (globais, locais de goroutine que dorme, o caso da variável capturada) fica invisível,
e o perfil aponta a linha bloqueada, não o
returnou ocloseque faltou. Combine comgoleak. - Só channels e
sync: segundo o blog, goroutines bloqueadas em I/O de arquivo ou rede, syscalls diretas ou primitivas próprias nunca são reportadas (só documentação, não medido aqui). - Sob demanda, com um caso patológico: vazamentos comuns custam de 2x a 3x um GC, mas cadeias longas de goroutines dependentes custam segundos de marcação e milhares de pausas curtas. O blog sugere coletar periodicamente (por exemplo a cada 4 horas), já que o vazamento continua visível.
- O goleak enxerga o processo, não o teste: reporta toda goroutine que sobrou, inclusive as
legítimas de background (filtre com
IgnoreTopFunctionouIgnoreCurrent), e a documentação diz queVerifyNoneé incompatível comt.Parallel; nesse caso useVerifyTestMain. - Antes versus hoje: o Go 1.26 trazia o perfil atrás de
GOEXPERIMENT=goroutineleakprofile; o Go 1.27 removeu essa configuração e ligou o perfil.
Documentation Links
- Goroutine Leak Profiles, The Go Blog
- Go 1.27 Release Notes: Goroutine leak profile
- Proposal 74609: Goroutine leak detection via garbage collection
- runtime/pprof package documentation
- net/http/pprof package documentation
- go.uber.org/goleak package documentation
- Dynamic Partial Deadlock Detection and Recovery via Garbage Collection, ASPLOS 2025
- Unveiling and Vanquishing Goroutine Leaks in Enterprise Microservices, CGO 2024