Goroutines são baratas — alguns kilobytes de stack e alguns microssegundos para iniciar. Essa leveza é intencional: ela permite lançar milhares delas sem se preocupar com overhead. Mas essa facilidade tem um lado sombrio. Por serem tão simples de criar, é igualmente simples criá-las sem pensar em como — ou se — elas algum dia vão parar.
O garbage collector do Go pode recuperar memória que não está mais acessível. O stack de uma goroutine bloqueada e todas as variáveis capturadas pelo seu closure ainda são acessíveis — pela própria goroutine. O GC não vai coletá-las. Uma goroutine parada para sempre é uma residente permanente do seu processo: ela consome memória, retém quaisquer recursos que capturou e impede que esses recursos sejam liberados.
Isso é um goroutine leak: uma goroutine sem caminho de terminação.
Como goroutines terminam
Quem cria uma goroutine é responsável pelo seu ciclo de vida. O runtime não vai recuperá-la, e nenhuma outra parte vai pará-la por você. Com isso em mente, existem exatamente três formas legítimas de uma goroutine encerrar:
O trabalho atribuído está concluído. A goroutine termina sua tarefa e sua função retorna. É a saída mais simples e natural.
Um erro é encontrado. A goroutine encontra uma condição da qual não consegue se recuperar, reporta o erro por um channel ou outro mecanismo, e retorna.
O pai sinaliza a terminação. A goroutine é de longa duração — ela fica em loop indefinidamente aguardando trabalho — e o caller precisa explicitamente dizer a ela para parar. Sem esse sinal, a goroutine não tem como saber que não é mais necessária.
Sem ao menos um desses caminhos, uma goroutine jamais vai encerrar.
Como um leak se parece
O padrão de leak mais comum é uma goroutine bloqueada em um receive de channel sem remetente:
doWork cria um channel sem buffer, lança uma goroutine que lê dele e retorna sem nunca enviar nada. O caller descarta o channel, mas a goroutine ainda mantém uma referência interna a ele. O GC enxerga o channel como acessível e não o coleta. A goroutine permanece parada em <-ch indefinidamente.
O mesmo problema aparece no lado do send:
main lê um valor e descarta o channel. A goroutine producer tenta enviar o próximo valor, não encontra receptor e fica parada. Não há mecanismo para encerrá-la. A goroutine vazou.
Prevenindo leaks: o done channel
A solução canônica para o terceiro caminho de terminação — terminação sinalizada pelo pai — é o done channel. O caller cria um channel e o passa para a goroutine. Quando a goroutine deve parar, o caller fecha o channel. A goroutine monitora isso em um loop com select:
Fechar done torna o case <-done no select imediatamente pronto. Na próxima iteração do loop, a goroutine escolhe esse case e retorna. defer close(ch) dispara na saída, sinalizando para consumidores downstream que o channel está esgotado.
O done channel é tipado como <-chan struct{} (somente leitura, tamanho zero) no parâmetro da goroutine. Isso é deliberado: a goroutine não consegue fechá-lo acidentalmente, e o channel não carrega dados — seu único papel é como sinal.
Prevenindo leaks: context.Context
O done channel é um mecanismo manual e ponto-a-ponto. A biblioteca padrão do Go oferece uma abstração de nível mais alto para o mesmo problema: context.Context.
Um Context carrega um sinal de cancelamento, um deadline opcional e pares chave-valor opcionais. Criticamente, o cancelamento se propaga pela árvore de contextos: quando um contexto pai é cancelado, todos os contextos derivados dele também são cancelados. Isso torna o context a ferramenta certa quando o cancelamento precisa fluir por múltiplas goroutines ou limites de função.
context.WithTimeout retorna um contexto que se cancela automaticamente após dois segundos. Quando cancela, ctx.Done() é fechado — exatamente como o done channel manual, mas agora o runtime gerencia o timer. ctx.Err() diz à goroutine por que foi cancelada: context.DeadlineExceeded para timeouts, context.Canceled para cancelamento explícito.
defer cancel() aparece imediatamente após WithTimeout. Isso não é opcional: mesmo que o timeout dispare naturalmente, chamar cancel libera o timer interno e os recursos associados. Ignorá-lo causa um vazamento do timer.
Detectando leaks
Goroutine leaks são invisíveis até que se tornem visíveis — geralmente como um aumento lento e constante no uso de memória ou no número de goroutines sob tráfego sustentado.
runtime.NumGoroutine() retorna o número de goroutines atualmente ativas. Em testes, você pode comparar a contagem antes e depois de executar o código sob teste:
O time.Sleep é uma aproximação grosseira — o encerramento de uma goroutine não é síncrono com close(done). Para detecção precisa, sinalize a conclusão via sync.WaitGroup antes de fazer a segunda medição.
Para uma solução mais ergonômica, o pacote goleak (github.com/uber-go/goleak) se integra ao pacote de testes e reporta goroutines que sobrevivem ao teste:
Com VerifyTestMain, qualquer teste que deixe uma goroutine rodando após seu retorno vai falhar com uma descrição clara da goroutine vazada — seu stack trace, local de criação e a chamada bloqueante em que está presa.
Um checklist para cada goroutine
Antes de lançar uma goroutine, confirme:
- Saída natural — a função retorna quando o trabalho está concluído?
- Saída por erro — ela retorna (em vez de bloquear) quando algo dá errado?
- Caminho de cancelamento — há um done channel ou
context.Contextque o caller pode usar para encerrá-la? - Responsabilidade do caller — alguém está de fato chamando
close(done)oucancel()quando a goroutine não é mais necessária?
Se qualquer um desses pontos estiver faltando, a goroutine pode vazar. O custo de responder a essas perguntas uma vez, no momento em que você escreve o go, é muito menor do que diagnosticar um vazamento de memória em produção semanas depois.