O que é o Garbage Collector do Go
O garbage collector (GC) e o componente da runtime do Go responsável por liberar automaticamente a memoria que seu programa não usa mais. Sem ele, você teria que fazer malloc e free manual como em C. Com ele, você escreve código e a linguagem cuida da memoria por você.
O GC do Go sempre foi conhecido por dois pontos: pausas curtas (baixa latência) e bom desempenho geral. Mas nas versões recentes, especialmente a partir do Go 1.19 com o GC GOEXPERIMENT e nas versões 1.21 em diante, a equipe fez mudanças profundas na forma como o GC percorre o heap, reduzindo ainda mais as pausas e tornando o comportamento mais previsível.
Para um desenvolvedor Go, entender como o GC funciona não e só curiosidade académica. Saber o que acontece por baixo dos panos ajuda a escrever código que gera menos pressão no GC, evita alocações desnecessárias e resulta em aplicações mais rápidas e baratas de rodar.
Como o novo GC percorre o heap
O GC do Go usa um algoritmo chamado tri-color mark-and-sweep concorrente. Concorrente significa que ele roda ao mesmo tempo que o seu programa, em vez de parar tudo (stop-the-world) para fazer a coleta. Isso e o que garante as pausas curtas.
O algoritmo divide todos os objetos na memoria em três categorias durante a coleta:
- Branco: objeto ainda não visitado. No inicio da coleta, tudo começa branco.
- Cinza: objeto foi encontrado e esta na fila para ser processado. Os filhos dele ainda não foram visitados.
- Preto: objeto e todos os seus filhos foram visitados. Ele é considerado vivo.
O GC começa pelos objetos raiz (variáveis globais, stack das goroutines), os coloca em cinza, depois processa cada cinza: visita seus filhos (os coloca em cinza), e marca o objeto atual como preto. Ao final, tudo que ficou branco e lixo e pode ser liberado.
Você pode ver o GC em ação com a variável de ambiente GODEBUG=gctrace=1. Cada linha de saída mostra quando uma coleta aconteceu, quanto tempo durou e quanta memoria foi liberada.
Principais mudanças nas versões recentes
A grande mudança das versões recentes foi na forma como o GC divide o trabalho entre a thread dedicada ao GC e as goroutines da aplicação. Antes, as goroutines podiam ser recrutadas para ajudar o GC quando ele estava sobrecarregado, o que causava picos de latência imprevistos.
O novo design separou melhor as responsabilidades e reduziu a quantidade de trabalho que as goroutines da aplicação precisam fazer para o GC. O resultado prático: pausas mais curtas e mais consistentes, especialmente em aplicações com alta taxa de alocação.
- Write barriers mais eficientes: o mecanismo que garante que objetos novos criados durante a coleta não sejam perdidos ficou mais leve.
- Sweeping preguiçoso: a limpeza da memoria liberada acontece de forma mais incremental, distribuindo o custo ao longo do tempo.
- Melhor heurística de trigger: o GC inicia uma nova coleta no momento certo, nem cedo demais (desperdiçando CPU) nem tarde demais (deixando o heap crescer muito).
Como começar: ferramentas para observar o GC
Você não precisa fazer nada para usar o novo GC. Ele é automático. Mas para observar e ajustar o comportamento, você tem algumas ferramentas:
# Ver traces do GC em tempo real
GODEBUG=gctrace=1 ./sua-aplicação
# Saída típica de cada linha:
# gc 1 @0.012s 2%: 0.021+2.1+0.011 ms clock, ...
# gc 1 = ciclo de GC número 1
# @0.012s = tempo desde o inicio do programa
# 2% = porcentagem do tempo em GC
# 0.021+2.1+0.011 = fases: STW sweep termination + marcação concorrente + STW mark termination# Ajustar o alvo de uso de memoria (GOGC)
# Padrão: 100 (trigger quando heap dobra de tamanho)
GOGC=200 ./sua-aplicação # GC menos frequente, mais memoria usada
GOGC=50 ./sua-aplicação # GC mais frequente, menos memoria
# Go 1.19+: limite absoluto de memoria
GOMEMLIMIT=512MiB ./sua-aplicação # nunca usar mais que 512MBO GOMEMLIMIT introducido no Go 1.19 e particularmente útil em containers. Em vez de o GC adivinhar quando acionar a coleta, você diz explicitamente o limite máximo de memoria que o programa pode usar, e o GC se ajusta para respeitar esse limite.
Exemplo prático: medindo o impacto do GC
Para ver o efeito real na sua aplicação, o pacote runtime expõe métricas detalhadas:
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// Criar pressão no GC com muitas alocações pequenas
for i := 0; i < 1000; i++ {
_ = make([]byte, 1024)
}
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
fmt.Printf("Ciclos de GC: %d\n", stats.NumGC)
fmt.Printf("Pausa total (ms): %.2f\n", float64(stats.PauseTotalNs)/1e6)
fmt.Printf("Pausa máxima (ms): %.2f\n", float64(stats.PauseNs[(stats.NumGC+255)%256])/1e6)
fmt.Printf("Heap em uso (MB): %.2f\n", float64(stats.HeapInuse)/1024/1024)
fmt.Printf("Heap liberado (MB): %.2f\n", float64(stats.HeapReleased)/1024/1024)
time.Sleep(100 * time.Millisecond) // dar tempo para GC rodar
}Para benchmarks mais precisos, use o pprof com o perfil de heap. Ele mostra quais funções estão alocando mais memoria e são as melhores candidatas para otimização.
Use sync.Pool para reutilizar objetos de vida curta que são criados e descartados frequentemente (como buffers de leitura). Isso reduz drasticamente a pressão no GC sem precisar mudar a lógica do seu código.
Comparação com GCs de outras linguagens
Cada linguagem tem sua abordagem para o GC, com tradeoffs diferentes. Comparar ajuda a entender por que o Go fez as escolhas que fez.
Java (ZGC/G1GC): pausas muito curtas também, mas a JVM e mais pesada em memoria. O GC do Java tem mais parâmetros de tuning, o que da mais controle mas também mais complexidade.
Python: usa reference counting + GC cíclico. O reference counting e imediato (objeto morre assim que ninguém mais aponta para ele), mas ciclos de referência precisam do GC separado. Mais previsível para objetos simples, mas com overhead constante por objeto.
Rust: não tem GC. O compilador garante segurança de memoria via sistema de ownership em tempo de compilação. Zero overhead em runtime, mas complexidade de programação maior.
O Go ficou num ponto médio intencional: sem o overhead constante do reference counting do Python, sem a complexidade do ownership do Rust, com pausas menores que versões antigas da JVM. Para a maioria dos serviços web e de infra, e o tradeoff certo.
GOGC=off desabilita completamente o GC. Isso pode fazer sentido em programas de vida muito curta (CLIs), mas em serviços de longa duração vai encher a memoria até o processo ser morto pelo OS.
Pontos positivos e limitações
O GC do Go e genuinamente bom. As pausas são tipicamente abaixo de 1ms em aplicações bem escritas, e o modelo e simples o suficiente para que a maioria dos desenvolvedores nunca precise pensar nele explicitamente.
A principal limitação ainda e o throughput: linguagens sem GC como C, C++ e Rust conseguem processar mais operações por segundo porque não tem overhead de coleta. Para aplicações onde latência ultra-baixa e o principal requisito, o GC do Go pode ser um gargalo.
- Positivos: pausas curtas, configuração simples (GOGC e GOMEMLIMIT), sem memory leaks por referência circular, modelo de memoria previsível.
- Limitações: overhead de throughput comparado a linguagens sem GC, tuning pode ser necessário em cargas extremas, alocações frequentes de objetos pequenos ainda são caras.
Casos de uso reais
Serviços de API de alta concorrência: Go com goroutines e o GC de baixa latência e uma combinação natural. Vários serviços da Cloudflare, Uber e Twitch rodam em Go exatamente por isso.
Ferramentas de linha de comando: CLIs Go iniciam rápido e o GC raramente e um problema (vida curta do processo). Docker, Kubernetes kubectl, Terraform e todos escritos em Go.
Proxies e gateways: Caddy, Traefik e outros proxies em Go se beneficiam do GC previsível para manter latências baixas mesmo sob carga alta.
Workers de processamento em batch: para workers que processam filas (Kafka, RabbitMQ), o GC do Go permite ajustar GOMEMLIMIT para o container e deixar o sistema se auto-regular sem intervenção manual.
Dicas e boas práticas
Defina GOMEMLIMIT como 90% do limite de memoria do seu container. Isso evita que o processo seja morto pelo OOM killer do Kubernetes enquanto ainda deixa espaço para o overhead da runtime.
Prefira slices pre-alocados com capacidade definida (make([]T, 0, cap)) em vez de append sem capacidade inicial. Evita realocações desnecessárias que geram pressão no GC.
Closures que capturam variáveis grandes fazem essas variáveis escaparem para o heap mesmo que pudessem ficar na stack. Use o compilador com -gcflags='-m' para ver o que esta escapando para o heap no seu código.
O pacote golang.org/x/perf/benchstat e ideal para comparar resultados de benchmark antes e depois de uma otimização. Ele calcula significância estatística e evita conclusões erradas por variação normal.
Vale a pena estudar o GC do Go
Para a maioria dos desenvolvedores Go, o GC e transparente o suficiente para ignorar no dia a dia. Mas quando a latência importa, saber como ele funciona faz toda a diferença.
Entender o tri-color mark-and-sweep, saber que GOMEMLIMIT existe e usar GODEBUG=gctrace=1 para observar o comportamento real são habilidades que diferenciam um dev Go intermediário de um sénior. Não e necessário memorizar todos os detalhes, mas saber onde olhar quando houver problema de performance e essencial.
O novo GC e uma melhoria real e tangível. Se você tem uma aplicação Go em produção, vale a pena atualizar para as versões mais recentes já pelo ganho de latência no GC, mesmo sem mudar nenhuma linha do seu código.
Comentários
Deixar um comentárioVocê precisa ter uma conta no Do Zero ao Junior para comentar.