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:
- Uma goroutine só executa num
P. Há exatamenteGOMAXPROCSdeles, então há no máximoGOMAXPROCSgoroutines executando código Go ao mesmo tempo. - Work stealing:
Pcom fila vazia rouba metade da fila de outroP(ou pega da fila global, ou do netpoller). - Preempção assíncrona (Go 1.14+): o
sysmonenvia um sinal para interromper goroutine que roda há mais de ~10 ms. Antes disso, laço apertado sem chamada de função travava oP. - Handoff em syscall bloqueante: se uma goroutine entra numa syscall que bloqueia de verdade (arquivo, DNS via cgo), o
Mfica preso a ela e oPé desacoplado e entregue a outroM. É por isso que o número de threads do processo pode ser muito maior queGOMAXPROCS. - I/O de rede não bloqueia thread: o netpoller (
epoll/kqueue) estaciona a goroutine e libera oP(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 é softEle 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
GOMEMLIMITnum 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
- 2. Goroutines e Channels
- 7. Memória, Slices e Maps por Dentro
- 5. Go em Produção
- 4. Versões, Módulos e Compatibilidade
- 2. Stack vs Heap
- Improper Instantiation
Parte de MOC Go · A Guide to the Go GC · Scheduler design docs