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.

💡
Dica

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 512MB

O 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.

🚀
Pro tip

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.

⚠️
Atenção

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

💡
Dica

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.

💡
Dica

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.

🔴
Cuidado

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.

🚀
Pro tip

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.