trilha

Tecnologias/01 - Linguagens/01 - Go7 min

Runtime, Scheduler e GC

Conceito

O runtime do Go é um mini sistema operacional embutido no seu binário: ele agenda goroutines, gerencia memória e coleta lixo. Entender o mecanismo é o que separa "meu serviço está lento" de "meu serviço está lento por causa disto".

O scheduler: G, M, P

G = goroutine     (milhares; barata, ~2 KB de stack inicial)
M = machine       (thread do SO; poucas)
P = processor     (contexto de execução; GOMAXPROCS deles)

   P0            P1            P2            P3
  [runq]        [runq]        [runq]        [runq]      ← fila local (256 slots)
    │             │             │             │
   M0            M1            M2            M3
                                  \
                             global runq + netpoller

Regras que governam a execução:

  1. Uma goroutine só executa num P. Há exatamente GOMAXPROCS deles, então há no máximo GOMAXPROCS goroutines executando código Go ao mesmo tempo.
  2. Work stealing: P com fila vazia rouba metade da fila de outro P (ou pega da fila global, ou do netpoller).
  3. Preempção assíncrona (Go 1.14+): o sysmon envia um sinal para interromper goroutine que roda há mais de ~10 ms. Antes disso, laço apertado sem chamada de função travava o P.
  4. Handoff em syscall bloqueante: se uma goroutine entra numa syscall que bloqueia de verdade (arquivo, DNS via cgo), o M fica preso a ela e o P é desacoplado e entregue a outro M. É por isso que o número de threads do processo pode ser muito maior que GOMAXPROCS.
  5. I/O de rede não bloqueia thread: o netpoller (epoll/kqueue) estaciona a goroutine e libera o P (2. Goroutines e Channels).

O ponto 4 explica um mistério comum: um serviço com GOMAXPROCS=4 mostrando 60 threads. Não é bug — são goroutines em syscalls bloqueantes.

GOMAXPROCS e cgroups

Historicamente o Go lia o número de CPUs da máquina, ignorando o limite do contêiner:

nó com 64 vCPU, contêiner com limite de 1 CPU
→ GOMAXPROCS = 64 → 64 threads disputando 1 CPU de cota
→ throttling do cgroup, p99 terrível, gráfico de CPU mostrando "40% de uso"

A correção por anos foi go.uber.org/automaxprocs. Desde o Go 1.25 o runtime considera o limite de CPU do cgroup ao definir GOMAXPROCS. Em Go 1.27 isso já vem de fábrica — mas confirme, porque muitos serviços ainda fixam a variável manualmente por herança.

// verifique em runtime, e logue na inicialização: economiza horas depois
slog.Info("runtime", "gomaxprocs", runtime.GOMAXPROCS(0), "numcpu", runtime.NumCPU())

O GC: mark & sweep concorrente, tricolor

O coletor do Go é concorrente, não-geracional, não-compactador e otimizado para latência:

1. Sweep Termination  → STOP THE WORLD (µs): prepara
2. Mark (concorrente) → percorre o grafo de objetos alcançáveis
                        as goroutines CONTINUAM rodando; o write barrier
                        registra ponteiros alterados durante a marcação
                        goroutines que alocam muito são forçadas a AJUDAR (mark assist)
3. Mark Termination   → STOP THE WORLD (µs): fecha a marcação
4. Sweep (concorrente)→ libera o que não foi marcado, sob demanda

As pausas são de microssegundos (não milissegundos) porque quase todo o trabalho é concorrente. O custo migrou de "pausa" para CPU compartilhada com a aplicação e para o mark assist: sob alocação intensa, sua própria goroutine é recrutada para marcar, e a latência dela sobe.

O pacer: GOGC e GOMEMLIMIT

GOGC=100 (padrão) → dispara o próximo ciclo quando o heap DOBRAR
                    heap vivo 300 MB → alvo 600 MB

O problema em contêiner é direto: o pacer não sabe do limite de memória do cgroup.

limite do contêiner: 512 MB
heap vivo: 300 MB → alvo do GC: 600 MB → OOMKilled antes de coletar

GOMEMLIMIT (Go 1.19+) resolve dando ao GC um limite flexível do total gerenciado: ao se aproximar, ele coleta mais agressivamente em vez de estourar.

env:
  - name: GOMEMLIMIT
    value: "450MiB"      # ~85-90% do limite do contêiner
Estratégia Quando
GOGC=100 + GOMEMLIMIT o padrão recomendado: deixa o GC trabalhar, com rede de segurança
GOGC=off + GOMEMLIMIT serviço com heap previsível e latência crítica: GC só roda perto do limite
GOGC=400 + GOMEMLIMIT troca RAM por CPU: menos ciclos de GC, heap maior
GOMEMLIMIT é soft

Ele cobre o que o runtime gerencia — não stacks de thread do SO, não memória de cgo, não mmap de bibliotecas. Deixe 10–15% de folga em relação ao limite real do contêiner, ou o OOM killer chega antes.

Trade-offs

GC não-compactador evita mover objetos e aceita fragmentação. Ponteiros permanecem válidos (o que simplifica cgo e o runtime), e em troca o heap pode ficar esparso. Na prática o size-class allocator do Go controla bem isso.

Não-geracional é uma escolha deliberada. A maioria dos GCs modernos separa objetos novos de velhos (a hipótese geracional). O Go aposta em escape analysis (7. Memória, Slices e Maps por Dentro): o que pode ficar na stack, fica — e nem chega ao GC. Menos maquinaria, e mais dependência de o compilador acertar.

Sem controle fino, por projeto. Não há como escolher outro coletor, definir tamanho de região ou fixar pausa máxima como na JVM. Você tem GOGC, GOMEMLIMIT e a decisão de alocar menos. É simples, e é limitante quando você precisa de mais.

O trade-off central: o runtime do Go otimiza para "funciona bem sem ajuste" e cobra em teto de controle. O caminho de otimização não é configurar o GC — é reduzir alocação. Sem GOMEMLIMIT num contêiner, porém, o padrão é francamente hostil, e essa é a única configuração que todo serviço em produção precisa.

Exemplo prático

Medir antes de mexer

import _ "net/http/pprof"   // em porta interna! ver [[5. Go em Produção]]
# quem aloca (memória VIVA agora)
go tool pprof -http=: 'http://localhost:6060/debug/pprof/heap'

# quem aloca ACUMULADO (mesmo já liberado) — é aqui que se caça pressão de GC
go tool pprof -http=: 'http://localhost:6060/debug/pprof/allocs'

# o trace mostra o GC no tempo: pausas, mark assist, uso de P
curl -o t.out 'http://localhost:6060/debug/pprof/trace?seconds=5' && go tool trace t.out

# goroutines bloqueadas para sempre (Go 1.27)
go tool pprof 'http://localhost:6060/debug/pprof/goroutineleak'
# o log do GC, sem instrumentar nada:
GODEBUG=gctrace=1 ./app
# gc 42 @31.234s 3%: 0.018+12+0.005 ms clock, 0.14+4.2/11/0+0.04 ms cpu,
#                    412->418->205 MB, 425 MB goal, 8 P
#          ↑ 3% = fração do tempo total gasto em GC. Acima de ~10% investigue alocação.
#            412->418->205: heap no início do GC -> no fim -> vivo

Reduzir alocação: os quatro padrões que rendem mais

// 1) Pré-alocar quando o tamanho é conhecido: evita N reallocs + N cópias
out := make([]Item, 0, len(in))          // ❌ var out []Item

// 2) strings.Builder em vez de concatenação em laço
var b strings.Builder
b.Grow(len(itens) * 16)                  // uma alocação só
for _, it := range itens { b.WriteString(it) }

// 3) sync.Pool para buffers de vida curta e alto volume
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func handle(w io.Writer, v any) error {
	buf := bufPool.Get().(*bytes.Buffer)
	defer func() { buf.Reset(); bufPool.Put(buf) }()
	// ... usa buf ...
	_, err := buf.WriteTo(w)
	return err
}

// 4) Evitar boxing em interface{} / any dentro de caminho quente
//    Cada valor que entra num `any` costuma alocar. Genéricos evitam isso.
func Max[T cmp.Ordered](a, b T) T { if a > b { return a }; return b }

E a medição que fecha o ciclo:

go test -bench=. -benchmem ./...
# BenchmarkAntes-8   120000   9812 ns/op   4096 B/op   37 allocs/op
# BenchmarkDepois-8  480000   2410 ns/op    512 B/op    2 allocs/op
#                                            ↑ allocs/op é a métrica que importa

Como isso falha em produção

Sintoma Causa
p99 alto com CPU "baixa" GOMAXPROCS > cota do cgroup → throttling (Go < 1.25 sem automaxprocs)
OOMKilled com heap "pequeno" falta GOMEMLIMIT: o GC mirava o dobro do heap vivo
gctrace mostrando 20–30% em GC alocação excessiva no caminho quente; olhe allocs
Latência irregular sob carga mark assist: as goroutines que alocam estão ajudando o GC
Threads do SO em centenas syscalls bloqueantes prendendo M (arquivo, DNS via cgo)
Memória do processo não cai após pico o runtime devolve páginas ao SO devagar; debug.FreeOSMemory() força (raramente necessário)
Goroutines crescendo sem parar vazamento; use o perfil goroutineleak (Go 1.27)

Aprofundamento aplicado

Memória retida e taxa de alocação são problemas diferentes

Um cache que cresce sem limite aumenta o conjunto de objetos vivos. Um parser que cria muitos objetos temporários pode manter pouca memória viva e ainda consumir CPU no GC. Compare perfil de memória retida com alocação acumulada antes de escolher a correção.

Evidência Hipótese para investigar
Heap vivo cresce após cada ciclo Retenção, cache ou fila sem limite
Heap vivo estável; alocação acumulada alta Objetos temporários e cópias
RSS alto com heap Go pequeno Memória fora do heap, cgo ou mapeamentos
CPU de GC sobe perto do limite Pouca folga para o conjunto vivo e as alocações

Abaixar GOMEMLIMIT não faz objetos ainda alcançáveis desaparecerem. Um limite incompatível com o conjunto vivo pode aumentar o esforço do GC sem resolver o consumo. Referência: limite de memória.

Exercício: compare um buffer reutilizado com uma alocação por requisição, mantendo o mesmo trabalho e entrada. Resultado esperado: explique mudanças em bytes alocados, memória retida e tempo; reduzir uma métrica não garante melhorar todas.

Relacionado


Parte de MOC Go · A Guide to the Go GC · Scheduler design docs

Buscar

Busca por título, seção e texto das notas