Detectando goroutine leaks (vazamento de goroutines) no golang 1.27
No golang 1.27 podemos verificar goroutine leaks de uma forma nativa sem precisar de flags experimentais.
O que é uma goroutine leaks?
As *goroutines* sincronizam-se ou trocam informações por meio de primitivas de concorrência compartilhadas, como canais (*channels*), *locks* e *wait groups*. Ao se comunicarem, as *goroutines* frequentemente ficam bloqueadas nessas primitivas — ou seja, aguardam até que uma determinada condição seja atendida; exemplos comuns incluem esperar para adquirir um *mutex* que já está sendo utilizado ou aguardar o recebimento de uma mensagem em um canal. As *goroutines* também podem ficar bloqueadas em operações do sistema operacional, como a leitura de um *socket* de rede ou de um arquivo.
Podemos considerar que uma goroutine sofreu um vazamento se ela estiver bloqueada, mas as condições necessárias para desbloqueá-la nunca puderem ser atendidas. Com o tempo, o acúmulo de goroutines vazadas degrada o desempenho devido ao uso excessivo de memória (seja pelas próprias goroutines vazadas ou pela memória que elas referenciam), bem como ao consumo de CPU pelo coletor de lixo, especialmente se o `GOMEMLIMIT` estiver sendo utilizado.
O Go 1.27 introduz o *profiler* de vazamento de goroutines, um mecanismo flexível e leve para identificar vazamentos de goroutines em programas Go em execução, inclusive em sistemas de produção. Ao contrário de abordagens anteriores, que exigem análise humana, este mecanismo é preciso e gera poucos ou nenhum falso positivo. A contrapartida é que ele se limita a um subconjunto de vazamentos de goroutines: aquelas permanentemente bloqueadas em canais ou primitivas do pacote `sync`. Felizmente, isso já abrange uma parcela muito significativa dos vazamentos de goroutines, como veremos em nossos exemplos.
Vamos ver um exemplo prático
package main
import (
"fmt"
"os"
"runtime/pprof"
"time"
)
func main() {
buscarComVazamento()
buscarSemVazamento()
consumidorComVazamento()
consumidorSemVazamento()
fmt.Println("=== goroutineleak ===")
pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
}
func buscarComVazamento() int {
// canal SEM buffer
ch := make(chan int)
go func() {
time.Sleep(100 * time.Millisecond)
// VAZA: o timeout (10ms) já fez a função retornar, então ninguém
// mais recebe de ch. Sem buffer, o envio bloqueia para sempre.
ch <- 1
}()
// espera a goroutine chegar ao envio e ficar bloqueada, para o perfil
// conseguir enxergá-la (só para demonstração)
defer time.Sleep(150 * time.Millisecond)
select {
case v := <-ch:
return v
case <-time.After(10 * time.Millisecond):
return -1
}
}
func buscarSemVazamento() int {
// canal COM buffer de 1: o envio completa mesmo sem receptor
ch := make(chan int, 1)
go func() {
time.Sleep(100 * time.Millisecond)
// NÃO VAZA: mesmo com o timeout já tendo ocorrido, o valor é
// guardado no buffer, o envio retorna na hora e a goroutine termina.
ch <- 1
}()
select {
case v := <-ch:
return v
case <-time.After(10 * time.Millisecond):
return -1
}
}
func consumidorComVazamento() {
ch := make(chan int)
go func() {
// VAZA: após receber 1 e 2, o range volta a esperar um novo valor.
// Como ch nunca é fechado e ninguém mais o referencia, a goroutine
// fica bloqueada aqui para sempre.
for v := range ch {
fmt.Println("vazando recebeu", v)
}
fmt.Println("vazando terminou") // nunca será impresso
}()
ch <- 1
ch <- 2
// falta close(ch): é isso que impede o range de terminar
}
func consumidorSemVazamento() {
ch := make(chan int)
go func() {
// NÃO VAZA: o close(ch) abaixo faz o range terminar depois de
// consumir todos os valores, e a goroutine retorna normalmente.
for v := range ch {
fmt.Println("ok recebeu", v)
}
fmt.Println("ok terminou")
}()
ch <- 1
ch <- 2
close(ch) //sinaliza "não há mais valores" e encerra o range
}Lembramos que temos que estar usando a versão 1.27 do Go. Veja o código acima bem simples importamos o runtime do pprof e damos a saída no final do código pprof.Lookup(“goroutineleak”).WriteTo(os.Stdout, 1). Este exemplo vai printar no terminal:
vazando recebeu 1
vazando recebeu 2
ok recebeu 1
=== goroutineleak ===
ok recebeu 2
ok terminou
goroutineleak profile: total 2
1 @ 0x102805248 0x10279e224 0x10279de38 0x10285909c 0x10280b454
# 0x10285909b main.buscarComVazamento.func1+0x3b /Users/rafaelfernandosouzaribeiro/go/teste/goroutine-leak-profiles/cmd/exec-terminal/main.go:27
1 @ 0x102805248 0x10279f0e0 0x10279ec54 0x1028591a8 0x10280b454
# 0x1028591a7 main.consumidorComVazamento.func1+0x87 /Users/rafaelfernandosouzaribeiro/go/teste/goroutine-leak-profiles/cmd/exec-terminal/main.go:67
O log mais importante é a saída do goroutineleak profile: total 2 que mostra as duas funções com vazamento.
Agora vamos ver um exemplo em http
package main
import (
"fmt"
"net/http"
_ "net/http/pprof" // Registra os endpoints de debug automaticamente
)
// Endpoint com vazamento proposital
func leakyHandler(w http.ResponseWriter, r *http.Request) {
ch := make(chan int)
// Esta goroutine ficará travada para sempre tentando enviar para um canal sem leitor
go func() {
ch <- 42
}()
w.Write([]byte("Requisição processada (com vazamento!)"))
}
func main() {
http.HandleFunc("/leaky", leakyHandler)
fmt.Println("Servidor rodando em http://localhost:8080")
// O pprof estará disponível em http://localhost:8080/debug/pprof/
http.ListenAndServe(":8080", nil)
}
Neste exemplo temos só handler com vazamento, para conferir o leak você deve executar o endpoint: http://localhost:8080/leaky e depois verificar a análise: http://localhost:8080/debug/pprof/goroutineleak?debug=1 ou http://localhost:8080/debug/pprof/?debug=1.
Lembrando que importamos o net/http/pprof. O pacote expõe endpoints HTTP que permitem inspecionar o estado interno do processo Go em tempo de execução (profiling).

0 comentários