Goroutine leak e um dos problemas mais traiçoeiros em sistemas Go de longa duracao. Sua API continua respondendo, os testes passam e nada parece obviamente quebrado. Mesmo assim, o numero de goroutines cresce sem parar, o consumo de memoria sobe e, algumas horas ou dias depois, a aplicacao começa a degradar.
Com o foco crescente do ecossistema em observabilidade e diagnostico – vide artigos sobre Flight Recorder no Go 1.25, padroes de concorrencia em Go e context.Context – o tema voltou ao radar em 2026. O Go 1.26 tambem reforcou a area de diagnostico com novidades experimentais no runtime e em runtime/pprof, tornando este o momento certo para revisar como identificar e corrigir vazamentos de goroutines.
Se voce trabalha com filas, workers, streaming, APIs HTTP ou microsservicos, este guia mostra os sinais de alerta, os padroes que causam leak e um fluxo pratico para investigar o problema em producao.
O que e um goroutine leak
Um goroutine leak acontece quando uma goroutine foi criada para executar uma tarefa finita, mas fica bloqueada indefinidamente ou continua viva sem necessidade. Diferente de um memory leak classico de linguagens sem garbage collector, aqui o problema raramente e “memoria perdida”. O que vaza e o trabalho em aberto: stacks, timers, referencias, conexoes e estruturas associadas a essas goroutines.
Na pratica, os sintomas mais comuns sao:
- crescimento gradual do numero de goroutines
- aumento de latencia em horarios de pico
- memoria subindo sem cair totalmente apos bursts
- workers presos aguardando channel, lock ou I/O
- CPU aparentemente normal, o que dificulta a deteccao
Em sistemas concorrentes, vazamentos pequenos se acumulam. Uma goroutine presa por request pode parecer irrelevante. Dez mil requests depois, ela se transforma em incidente.
Sinais de alerta em producao
O primeiro passo e medir. Exponha o numero de goroutines como metrica e acompanhe tendencia, nao apenas valor absoluto. Um servico com 300 goroutines pode estar saudavel; outro com 80 pode estar vazando. O que importa e o crescimento sustentado sem retorno ao baseline.
Se voce ja usa logging estruturado com slog, vale registrar contadores periodicos e correlacionar com deploys, endpoints e filas. Outra boa pratica e registrar o volume de workers ativos, filas pendentes e timeouts disparados.
Um endpoint simples com runtime.NumGoroutine() ja ajuda:
package main
import (
"encoding/json"
"net/http"
"runtime"
"time"
)
type healthResponse struct {
Status string `json:"status"`
Goroutines int `json:"goroutines"`
CheckedAt time.Time `json:"checked_at"`
QueueDepth int `json:"queue_depth"`
WorkersRunning int `json:"workers_running"`
}
func healthHandler(queueDepth, workersRunning func() int) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Goroutines: runtime.NumGoroutine(),
CheckedAt: time.Now(),
QueueDepth: queueDepth(),
WorkersRunning: workersRunning(),
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
}
Esse endpoint nao resolve o leak, mas acelera a percepcao do problema. Quando a metrica de goroutines sobe junto com timeout, retries ou backlog, voce tem um excelente ponto de partida.
Como goroutines vazam na pratica
A maioria dos leaks aparece em alguns padroes recorrentes.
1. Goroutine esperando em channel sem cancelamento
func processarPedidos(in <-chan string) {
for pedido := range in {
go func(p string) {
resultado := consultarServicoExterno(p)
_ = resultado
}(pedido)
}
}
Se consultarServicoExterno travar ou bloquear para sempre, suas goroutines vao se acumular. O mesmo vale para goroutines que tentam escrever em um channel sem consumidor.
2. HTTP sem timeout
client := &http.Client{}
resp, err := client.Get("https://api.externa.com/dados")
Sem timeout e sem context, uma chamada externa pode manter a goroutine viva por muito mais tempo do que o esperado. Em carga alta, isso vira uma fabrica de leaks.
3. Worker que nunca encerra
func worker(jobs <-chan Job) {
for {
job := <-jobs
processar(job)
}
}
Se o channel for fechado, esse codigo pode entrar em comportamento incorreto. O padrao idiomatico e usar for job := range jobs e combinar com cancelamento explicito quando houver ciclo de vida de servico.
4. select sem ramo de cancelamento
Quem leu nosso guia sobre uso correto de context.Context ja viu este ponto: toda operacao potencialmente longa deve ter uma forma clara de parar.
func consumir(ctx context.Context, ch <-chan string) error {
for {
select {
case msg := <-ch:
if err := processarMensagem(msg); err != nil {
return err
}
}
}
}
Se ch parar de receber mensagens, a goroutine fica bloqueada para sempre. Faltou um case <-ctx.Done().
Investigando com net/http/pprof
Quando voce suspeitar de leak, ative pprof e capture os perfis. Isso continua sendo a abordagem mais confiavel para descobrir onde as goroutines estao presas.
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// sobe sua aplicacao principal aqui
select {}
}
Depois, inspecione o perfil de goroutines:
go tool pprof http://localhost:6060/debug/pprof/goroutine
Ou visualize rapidamente no browser:
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/goroutine
Tambem vale usar o dump textual:
curl http://localhost:6060/debug/pprof/goroutine?debug=2
Esse dump mostra stacks agrupadas. Se voce encontrar centenas de goroutines paradas na mesma funcao, provavelmente achou o padrao vazando.
Exemplo realista de leak e correcao
A seguir, um exemplo comum: fan-out para chamadas externas sem limite e sem cancelamento.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func buscarStatusPedido(ctx context.Context, client *http.Client, id string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.externa.com/pedidos/"+id, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
return resp.Status, nil
}
func processarLote(ids []string) []error {
client := &http.Client{Timeout: 3 * time.Second}
sem := make(chan struct{}, 10)
erros := make(chan error, len(ids))
for _, id := range ids {
id := id
sem <- struct{}{}
go func() {
defer func() { <-sem }()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := buscarStatusPedido(ctx, client, id)
erros <- err
}()
}
for i := 0; i < cap(sem); i++ {
sem <- struct{}{}
}
close(erros)
var resultado []error
for err := range erros {
resultado = append(resultado, err)
}
return resultado
}
func main() {
fmt.Println("processador pronto")
}
O codigo acima melhora muito a situacao porque adiciona:
- limite de concorrencia com semaforo
- timeout no cliente HTTP
context.WithTimeoutpor operacao- fechamento claro do ciclo das goroutines
Em varios casos, leak de goroutine nao se resolve com uma ferramenta magica. Ele se resolve com design defensivo.
Boas praticas para evitar vazamentos
Estas regras evitam boa parte dos incidentes. E, se seu time compara modelos de concorrencia entre linguagens, vale observar como Rust aborda async/await e ownership, especialmente em servicos onde previsibilidade de recursos tambem pesa.
Estas regras evitam boa parte dos incidentes:
- Toda goroutine precisa de estrategia de encerramento. Antes de usar
go func(), pergunte: quando ela termina? - Toda chamada de rede precisa de timeout. Isso inclui HTTP, banco, cache e filas.
- Toda operacao longa deve aceitar
context.Context. Se a API nao aceita, avalie embrulhar com timeout ou trocar a dependencia. - Evite fan-out ilimitado. Use worker pool, semaforo ou fila com backpressure, como mostramos em padroes de concorrencia.
- Monitore
runtime.NumGoroutine(). Quase sempre o leak deixa pegadas antes de virar outage. - Faça testes de carga com observabilidade ligada. Um leak pequeno raramente aparece em teste unitario.
Se seu servico usa microsservicos e comunicacao distribuida, combine isso com tracing, logs estruturados e endpoints de debug. O guia de gRPC e microsservicos em Go mostra bem como chamadas remotas ampliam efeitos de timeout e retry.
E o que muda com o Go 1.26?
O Go 1.26 reforcou o arsenal de diagnostico com recursos experimentais que apontam diretamente para problemas de concorrencia e comportamento do runtime. Isso nao substitui pprof, mas indica uma direcao clara do ecossistema: tornar incidentes de producao mais observaveis, com menos adivinhacao.
Na pratica, o melhor ganho imediato para times Go continua sendo combinar tres camadas:
- codigo com cancelamento explicito
- metricas e alertas de goroutines
- captura de perfis quando a tendencia ficar anormal
Quem dominar esse fluxo vai depurar sistemas Go muito mais rapido do que times que olham apenas CPU e memoria.
Checklist rapido para revisar hoje
Antes do proximo deploy, revise estes pontos:
- handlers HTTP usam
r.Context()? - clients HTTP tem timeout configurado?
- workers param quando o contexto e cancelado?
- channels sao fechados por quem produz?
- existe metrica de goroutines no dashboard?
pprofesta disponivel em ambiente seguro de diagnostico?
Se voce responder “nao” para metade dessa lista, vale priorizar o assunto agora.
Conclusao
Goroutine leak nao costuma explodir de uma vez. Ele corrói o sistema aos poucos, escondido atras de stacks bloqueadas, chamadas lentas e fluxos sem cancelamento. A boa noticia e que Go oferece ferramentas excelentes para identificar o problema, e o ecossistema de 2026 esta cada vez melhor nessa area.
Comece pelo basico: use context, imponha timeout, limite concorrencia e meca runtime.NumGoroutine(). Quando a curva parecer estranha, entre com pprof. Esse fluxo simples resolve a maior parte dos casos reais.
Se quiser aprofundar, leia tambem nossos guias sobre context em Go, concorrencia com worker pools e pipelines e Flight Recorder para diagnostico em producao. Juntos, eles formam uma base muito forte para operar servicos Go em escala.