Voltar ao início

Go Internals - Garbage Collector

13 min de leitura
Cover Image for Go Internals - Garbage Collector
Lucas LemosLucas Lemos

Introdução

Em Go Internals - Alocador seguimos o mallocgc entregar spans e slots de size class. Quando objetos morrem, esses slots não passam por free(3) — o garbage collector decide o que ainda é alcançável e faz sweep do resto de volta às free lists do allocator.

Essa lógica mora em src/runtime/mgc.go, mgcmark.go, mgcsweep.go, mbarrier.go e mgcwork.go. O Go 1.23 ainda usa um coletor mark-sweep concorrente, não geracional e não compactante, com marcação tri-color. O mutator continua rodando durante a maior parte do mark e todo o sweep; pausas curtas stop-the-world (STW) emolduram as partes em que invariantes globais quebrariam se goroutines seguissem armazenando ponteiros livremente.

Este artigo percorre esse ciclo — como roots viram um grafo marcado, por que write barriers existem, o que as fatias STW custam e como GOGC define o ritmo.

O que o GC é responsável por fazer

O allocator entrega memória. O GC responde uma pergunta: quais objetos no heap ainda são alcançáveis a partir das roots? Todo o resto em um span varrido vira slots reutilizáveis.

Roots incluem:

  • stacks de goroutines (o compilador emitiu stack maps no artigo de Memória)
  • variáveis globais
  • registradores e spill slots enquanto uma G é escaneada em um safe point

O GC percorre arestas de ponteiro dessas roots por objetos em spans. Não usa refcount — ciclos vazariam, e inc/dec atômico em cada escrita de ponteiro brigaria com os objetivos de throughput do scheduler.

flowchart LR
  R["roots: stacks, globals"] --> A["objeto heap A"]
  A --> B["objeto heap B"]
  A --> C["objeto heap C"]
  B --> D["objeto heap D"]
  X["objeto inalcançável X"] -.->|"nunca marcado"| SW["sweep, slot reutilizado"]

Objetos inalcançáveis podem ficar em memória mapeada até o sweep rodar. Por isso o RSS (Resident Set Size) do heap pode permanecer alto depois de um pico de tráfego mesmo que a lógica do serviço tenha "liberado" tudo — a retenção em spans do artigo de Alocador e o timing de sweep deste artigo são a mesma história por dois ângulos.

Marcação tri-color

Durante o mark, cada objeto no heap é conceitualmente de uma de três cores:

Cor Significado
White Ainda não alcançado — lixo se continuar white quando o mark terminar
Grey Alcançado, mas ponteiros de saída podem não estar escaneados ainda
Black Alcançado e escaneado — filhos estão shaded

O invariante que o coletor mantém: nenhum objeto black guarda ponteiro para objeto white. Se isso quebrar, um objeto vivo pode ser confundido com lixo.

No início do mark, tudo é white. Roots são shaded grey. Workers puxam objetos grey, escaneiam campos ponteiro, shaded referentes em grey e tornam o objeto escaneado black. Quando não há grey, o que ainda é white é inalcançável.

stateDiagram-v2
  [*] --> White: heap no início do mark
  White --> Grey: descoberto de root ou campo
  Grey --> Black: campos ponteiro escaneados
  White --> Black: objeto noscan marcado sem scan de campos

Size classes noscan do artigo de Alocador pulam scan de campos — o mark bit é setado e o objeto vai direto a black. Ganho grande para backing stores de []byte sem ponteiros.

Mark bits ficam em metadados do span (mspan em mspan.go), não em headers de objeto. O runtime rastreia quais spans estão em mark e quais já foram varridos.

Um ciclo de GC: fases

Um ciclo completo é mais fácil de ler como timeline do que como um único nome de função. Os nomes abaixo seguem comentários e helpers em mgc.go.

sequenceDiagram
  participant M as goroutines mutator
  participant GC as workers de GC
  Note over M,GC: sweep termination (STW breve)
  Note over M,GC: mark setup (STW) — habilita barrier, escaneia roots
  M->>M: roda com write barriers
  GC->>GC: mark concorrente
  M->>GC: mark assist na alocação
  Note over M,GC: mark termination (STW) — esgota grey, flush workbufs
  GC->>GC: sweep concorrente de spans
  M->>M: aloca em spans varridos

Sweep termination (STW). Termina sweep de spans do ciclo anterior para o heap ficar consistente antes de um mark novo.

Mark setup (STW). Liga o write barrier, limpa estado de mark do ciclo novo, enfileira roots (stacks, globals) e sobe workers de mark em background. É uma das fatias STW que aparecem em profiles de latência quando GODEBUG=gctrace=1 reporta tempo STW.

Mark concorrente. Goroutines mutator rodam com write barriers ligados. Workers dedicados (gcBgMarkWorker em mgcmark.go) e mark assist em goroutines que alocam drenam a fronteira grey até os work buffers esvaziarem.

Mark termination (STW). Para o mundo de novo, termina mark restante, faz flush dos buffers gcWork por-P e verifica contabilidade. Depois disso o conjunto de objetos black é o heap vivo.

Sweep concorrente. Slots não marcados em spans voltam às free lists (mgcsweep.go). Alocação pode seguir em spans varridos ou ainda não varridos — o allocator checa estado do span antes de entregar um slot.

Go não compacta o heap. Objetos vivos ficam onde foram alocados; sweep só recicla buracos dentro dos spans.

Write barriers: mantendo o invariante tri-color

Enquanto o mark roda em paralelo com seu código, uma goroutine pode armazenar ponteiro para objeto white em objeto black depois que o scanner já passou naquele campo. Sem ajuda, o white nunca viraria grey — leak no sentido de correção (reclaim prematuro), não leak de memória da linguagem.

Go usa um write barrier híbrido (combinando ideias de Dijkstra e Yuasa), ligado durante toda a fase de mark concorrente. Chamadas de barrier inseridas pelo compilador ficam em escritas de ponteiro — não em toda escrita de campo, só onde um ponteiro é armazenado em memória do heap.

Efeito aproximado numa store *slot = ptr durante o mark:

  1. Shade o valor anterior em *slot se necessário (lado Yuasa — não esconder o subgrafo antigo).
  2. Shade ptr se o slot está em objeto black (lado Dijkstra — a nova aresta não pode apontar para white).

O lowering exato está em mbarrier.go e no pacote writebarrier do compilador (cmd/compile/internal/wb). Você não vê barriers no fonte normal; aparecem no assembly de -S como chamadas a runtime.gcWriteBarrier ou variantes buffered.

Barriers têm custo real de CPU durante o mark. Por isso alguns benchmarks ficam mais lentos quando GOGC=off não é opção e o heap é grande o bastante para o mark sobrepor o hot path. Fora do mark concorrente, barriers estão desligados e stores são moves simples.

Mark assist: alocação paga o mark

Se goroutines alocassem livremente enquanto workers de mark ficassem para trás, a fronteira grey nunca alcançaria e o heap vivo poderia crescer sem limite antes do mark termination.

Mark assist força goroutines que alocam a fazer trabalho de mark proporcional à taxa de alocação. mallocgc consulta gcAssistAlloc em mgcmark.go — se o GC está atrás do pace, seu caminho de make ou new também escaneia e shaded objetos antes de retornar.

Isso amarra CPU de GC à pressão de alocação de forma previsível: alocadores em rajada travam um pouco no caminho do allocator em vez de acumular dívida de mark até um STW longo.

Workers de mark em background rodam em Ps próprios quando disponíveis (gcBgMarkWorker). Sob carga pesada você verá workers em background e mark assist em CPU profiles em runtime.gcDrain e afins.

Sweep: devolvendo spans ao mallocgc

Mark responde "vivo ou morto." Sweep responde "colocar slots mortos de volta onde mcache pode dar pop."

Sweep percorre spans e limpa mark bits de objetos small não marcados, empurrando slots para free lists do span. Spans totalmente vazios podem devolver páginas às listas centrais do artigo de Alocador. Sweep é concorrente — não precisa de STW exceto quando o próximo ciclo precisa de estado de sweep limpo.

Até um span ser varrido, objetos mortos ainda parecem "ocupados" para o fast path do allocator. Taxas altas de alocação depois de um pico podem disparar sweep assist (sweep guiado por alocação) para mallocgc não entregar memória não varrida para sempre.

Spans de objeto large e casos especiais seguem caminhos paralelos em mgcsweep.go, mas o modelo mental é o mesmo: se o mark não alcançou um objeto, sua memória fica elegível para reuso.

STW: o que de fato pausa suas goroutines

"Go tem GC de baixa latência" costuma significar a maior parte do mark e todo o sweep são concorrentes, não que STW sumiu.

STW acontece quando o runtime precisa de snapshot global consistente:

  • ligar ou desligar write barrier em todos os Ps
  • escanear stacks no início do mark (com preemption points para goroutines cooperarem)
  • finalizar mark quando work buffers precisam ser drenados sem novas corridas de barrier

Tempos STW típicos ficam abaixo de um milissegundo em muitas cargas e podem subir com contagem de goroutines e profundidade de stack — cada stack é root. Milhares de goroutines ociosas com stacks profundas alongam mark setup mesmo alocando pouco.

Para serviços sensíveis a latência, picos STW costumam correlacionar com tamanho do heap e GOMAXPROCS, não só taxa de alocação. Profile com GODEBUG=gctrace=1 e execution traces do artigo de Scheduler para alinhar pausas com transições de fase.

GOGC e pacing

GOGC é variável de ambiente (padrão 100). Controla quando o próximo ciclo de GC começa, não o quão agressivo o sweep roda dentro de um ciclo.

Depois que um ciclo termina, o runtime registra o tamanho do heap vivo — chame de L. Com GOGC=100, o goal de trigger é aproximadamente L + L * (GOGC/100), ou seja, cerca de 2× o heap vivo antes do próximo ciclo. GOGC=200 permite cerca de 3×; GOGC=50 mira cerca de 1,5×.

GOGC menor → GC mais frequente → menos folga de heap, mais CPU em mark/sweep. GOGC maior → menos ciclos, heaps maiores, mark mais longo por ciclo quando finalmente roda.

GOGC=off desliga ciclos automáticos (ainda dá para forçar runtime.GC()). Útil em benchmarks; arriscado em produção a menos que nada aloque depois do warm-up.

O controlador de pacing em mgcpacer.go ajusta goals para o mark terminar antes do heap bater no trigger. Quando subestima, mark assist sobe — você paga em latência de alocação em vez de um STW maior.

Memory limit (Go 1.19+)

debug.SetMemoryLimit e GOMEMLIMIT acrescentam um teto soft de memória total (heap + certas estruturas do runtime). Quando o heap vivo se aproxima do limite, o pacer torna o GC mais agressivo — efetivamente tratando pressão de memória como um GOGC menor.

Não é falha dura de malloc no teto: o runtime tenta reclaim e devolver páginas ao SO (o pacote runtime/debug documenta o comportamento). Combine com métricas dos campos goal e live de GODEBUG=gctrace=1 ao tunar serviços que antes setavam GOGC muito alto.

Lendo gctrace

GODEBUG=gctrace=1 ./myapp

Cada linha é um ciclo de GC. Uma linha típica:

gc 42 @12.345s 2%: 0.12+1.5+0.08 ms clock, 0.10+0.9+0.05 ms cpu, 64->64->32 MB, 96 MB goal, 8 P

Campos úteis:

  • gc 42 — número do ciclo
  • @12.345s — tempo desde o início do processo
  • 2% — fração de wall time em GC até aqui
  • 0.12+1.5+0.08 ms clock — STW sweep term + mark concorrente + STW mark term (split aproximado; veja comentários em mgc.go para o mapeamento exato no 1.23)
  • 64->64->32 MB — heap no início do mark → heap no fim do mark → heap vivo após sweep
  • 96 MB goal — alvo de pacing que disparou este ciclo
  • 8 PGOMAXPROCS no momento do ciclo

Saltos repentinos nos componentes STW com alocação plana costumam significar mais stacks para escanear ou dívida de mark após pico de heap. Compare com profiles alloc_space do artigo de Alocador para ver se você está brigando com taxa de alocação ou retenção.

Um experimento pequeno de retenção

package main

import (
  "os"
  "strconv"
  "time"
)

var sink []*byte

func main() {
  n, _ := strconv.Atoi(os.Getenv("N"))
  if n == 0 {
    n = 100_000
  }
  for i := 0; i < n; i++ {
    b := make([]byte, 1024) // 1 KiB cada, slice header com ponteiro no heap
    sink = append(sink, b)
  }
  time.Sleep(2 * time.Second) // deixa um ciclo de GC rodar
}

Rode com N=100000, GODEBUG=gctrace=1. O heap vivo após sweep permanece grande porque sink mantém cada backing array alcançável. Remova o append em sink e só o transiente de crescimento da slice importa — mesma contagem de alocação, coluna live completamente diferente.

Esse é o grafo que o marker percorre: não seus nomes de variável, mas quais ponteiros ainda existem em roots e campos de objetos.

sync.Pool e GC

Entradas de sync.Pool podem ser limpas no início do ciclo de GC (docs do pacote sync). Pool não é forma de sair do mark — objetos em pool ainda estão no heap enquanto referenciados. Reduz taxa de alocação para o mark escanear menos objetos novos, com o custo de clears imprevisíveis quando você queria reuso.

Alavancas de tuning

Alavanca Efeito
Menos ponteiros no heap / menos alocação Grafo menor, menos mark — primeira opção
GOGC Troca tamanho de heap vs frequência de GC
GOMEMLIMIT / SetMemoryLimit Limita pressão de RSS, força pacing mais agressivo
runtime.GC() Ciclo manual; bloqueia até terminar — raro em código de app
GOMAXPROCS Mais Ps → mais workers de mark paralelos; também mais stacks no root scan

Não há flag suportada para trocar o coletor. GODEBUG=gcstoptheworld=1 existe para debug (força mais STW); não use em produção.

GC vs allocator: ponta a ponta

flowchart LR
  A["escape → mallocgc"] --> B["objeto vivo em span"]
  B --> C["mark acha caminho de ponteiro da root"]
  C --> D["objeto black / vivo"]
  B --> E["sem caminho das roots"]
  E --> F["sweep recicla slot"]
  F --> G["mcache alloc reusa slot"]

Escape analysis decide alocação. O allocator coloca objetos. O GC mantém o grafo de alcançabilidade e varre slots mortos para o allocator reutilizar sem free manual.

Conclusão

O garbage collector do Go é um sistema mark-sweep tri-color concorrente: pausas STW curtas preparam e finalizam o mark, write barriers preservam o invariante black-not-to-white enquanto goroutines rodam, e sweep alimenta spans de volta ao mallocgc. GOGC e o memory limit moldam quando os ciclos rodam e o quão forte o pacer empurra — não o caminho por alocação do artigo de Alocador.

Próximo na série é Built-in Types — como headers de slice, string e map aparecem na memória e o que o runtime faz quando você indexa, faz append ou percorre com range.