Go Internals - Alocador
Introdução
Em Go Internals - Memória vimos a escape analysis decidir quando um valor precisa viver no heap. Depois dessa decisão, o runtime precisa devolver um ponteiro rápido o bastante para churn de goroutines continuar barato.
Esse trabalho é do heap allocator em src/runtime/malloc.go, mheap.go, mcache.go e mcentral.go. O desenho vem do tcmalloc: size classes, caches por thread e um page heap central. O Go adapta isso para um runtime concorrente com GC tracing e um mcache por P.
Este artigo percorre os caminhos do allocator no Go 1.23 — o que mallocgc faz com um tamanho, onde entram locks e por que seu heap profile costuma mostrar mallocgc mesmo quando o código da aplicação parece inocente.
Páginas, spans e a arena do heap
O heap não é uma arena gigante de malloc. O runtime divide memória virtual em páginas — 8 KiB em plataformas 64-bit típicas (_PageShift = 13 em malloc.go). Páginas se agrupam em spans (mspan em mspan.go): sequências contíguas de páginas que ou guardam muitos objetos pequenos de uma size class ou um único objeto grande.
flowchart TB
subgraph arena["Arena do heap (memória virtual)"]
S1["mspan: 8 páginas, size class 48 B"]
S2["mspan: 1 página, tiny objects"]
S3["mspan: N páginas, objeto grande único"]
FREE["páginas livres"]
end
S1 --> O1["obj obj obj obj ..."]
S2 --> O2["slots de 16 B empacotados"]
S3 --> O3["uma alocação"]
Cada span rastreia:
- quantas páginas cobre
- qual size class atende (ou
0para spans de objeto grande) - uma free list de slots livres dentro do span
- metadados de GC (mark bits, estado de sweep — o artigo Garbage Collector continua daí)
Spans são a unidade que o allocator entrega e o GC depois reclama. Quando você lê mheap.alloc em mheap.go, no fundo está ligando páginas em spans.
Size classes: arredondar para cima é de propósito
Nem todo new(T) recebe exatamente sizeof(T) bytes. Objetos pequenos e médios passam por size classes — tabela fixa em sizeclasses.go (gerada; não edite à mão). Um struct de 24 bytes pode cair na classe de 32 bytes. Você paga alguns bytes de fragmentação interna para o allocator usar free lists em vez de um heap de propósito geral.
Buckets que mallocgc usa, em linhas gerais:
| Rota | Faixa de tamanho (64-bit típico) | O que acontece |
|---|---|---|
| Tiny | ≤ 16 bytes, sem ponteiros | Empacotados em blocos de 16 bytes; vários objetos podem compartilhar um slot |
| Small | > 16 bytes até 32 KiB (maxSmallSize) |
Lookup de size class → mcache → talvez mcentral → talvez mheap |
| Large | > 32 KiB | Span(s) dedicado(s) do mheap; tamanho arredondado para múltiplos de página |
type node struct {
next *node
val int
} // 16 bytes em 64-bit — small class, não tiny (tem campo ponteiro)
Objetos sem ponteiros ≤ 16 bytes podem compartilhar um bloco tiny. Adicione um campo ponteiro e o objeto sai do caminho tiny mesmo que o struct continue pequeno.
A tabela de size classes também registra noscan: se uma classe não tem ponteiros, o GC pula o scan desses objetos na fase de mark. Ganho real de throughput em workloads com muito []byte quando os objetos ficam em classes noscan.
mallocgc: a porta de entrada
Código gerado pelo compilador e helpers do runtime chamam mallocgc(size, typ, needzero). Você verá isso em heap profiles com mais frequência do que qualquer função sua.
flowchart TD
A["mallocgc(size, typ, needzero)"] --> B{"size == 0?"}
B -->|yes| Z["zerobase / struct vazio"]
B -->|no| C{"tiny? (≤16 B, noscan)"}
C -->|yes| T["tiny allocator do mcache"]
C -->|no| D{"size ≤ maxSmallSize?"}
D -->|yes| E["índice da size class"]
E --> F["mcache.alloc"]
F -->|miss| G["mcentral.cacheSpan"]
G -->|miss| H["mheap.alloc"]
D -->|no| I["mheap.allocLarge"]
Para objetos pequenos o hot path é: calcular a classe → pegar o próximo slot livre do mcache do P atual → retornar. Sem lock nesse caminho se o cache já tem um span com slots livres.
needzero diz se a memória precisa ser zerada antes do retorno. Páginas novas do heap vêm zeradas; slots reutilizados de um objeto liberado podem já estar limpos ou precisar de memclr conforme o estado do span — o runtime rastreia isso para a garantia de zero-value da linguagem sem zerar o heap inteiro a cada alocação.
mcache: um cache por P
Cada P (processor) tem um mcache — estrutura com free list por size class mais o estado do tiny allocator. Como código de goroutine só roda quando seu M segura um P, a maior parte das alocações acerta estado de cache local à thread sem lock central.
flowchart LR
subgraph P0["P₀"]
C0["mcache₀"]
C0 --> SC0["span para classe 32 B"]
C0 --> SC1["span para classe 96 B"]
end
subgraph P1["P₁"]
C1["mcache₁"]
end
G["G rodando em P₀"] -->|"mallocgc small"| C0
Quando mcache.alloc seca para uma classe, chama mcentral para reabastecer. O refill anexa um span com objetos livres; a G continua alocando do mcache até o span esgotar.
Por isso GOMAXPROCS afeta escalabilidade do allocator indiretamente: mais Ps significam mais mcaches e menos contenção em mcentral, até o ponto em que a carga é de fato paralela.
mcentral: spans compartilhados entre Ps
Cada size class tem um mcentral — lista de spans com espaço livre para aquela classe. mcentral.cacheSpan roda quando um mcache precisa de span novo. Toma lock na estrutura central, puxa um span parcial da lista nonempty ou pede páginas novas ao mheap se todas as listas estão vazias.
sequenceDiagram
participant G as G em P
participant MC as mcache
participant CENT as mcentral
participant MH as mheap
G->>MC: alloc 48 B
MC->>MC: pop slot livre
Note over MC: span vazio
MC->>CENT: cacheSpan
CENT->>CENT: lock, detach span
CENT-->>MC: mspan com slots livres
MC-->>G: ponteiro
Note over CENT,MH: listas centrais vazias
CENT->>MH: alloc páginas, montar span
MH-->>CENT: mspan novo
Contenção aparece aqui quando muitos Ps alocam a mesma size class ao mesmo tempo e as listas centrais ficam vazias. Menos comum que hits em mcache em serviços típicos, mas microbenchmarks que martelam um tamanho de struct de todos os cores podem disparar locks em mcentral — útil saber quando o profile culpa runtime.(*mcentral).cacheSpan.
mheap: páginas e objetos grandes
mheap é o page heap global. Dona runs de páginas livres, mapeia memória nova do SO quando precisa e monta spans. Alocações large (> 32 KiB) pulam mcache e mcentral: mheap aloca páginas suficientes, marca o span como large e devolve o endereço base do objeto.
Objetos grandes pagam arredondamento por página. Pedido de 40 KiB em páginas de 8 KiB precisa de seis páginas (48 KiB mapeados). Fragmentação interna na granularidade de página é o trade-off por alocação large O(1) sem manter free list separada de objetos grandes no hot path.
Quando o processo precisa de mais heap, o runtime cresce a arena — sysAlloc / mapeamento de plataforma em mem.go. RSS sobe em degraus; GODEBUG=gctrace=1 mostra tamanho do heap mas não detalhe por alocação. Para comportamento do allocator, alloc space no pprof é o melhor ângulo.
Tiny allocator: empacotando valores sub-16-byte
O caminho tiny passa batido porque escape analysis e size classes já esconderam o custo. Para objetos sem ponteiros até 16 bytes, mcache guarda um bloco tiny (16 bytes) e avança um cursor:
type point struct {
x, y int16 // 4 bytes no total, noscan
}
func manyPoints() []*point {
out := make([]*point, 1000)
for i := range out {
out[i] = &point{x: int16(i), y: int16(i)} // cada um escapa; elegível a tiny
}
return out
}
Vários point podem viver em um bloco tiny de 16 bytes antes do runtime abrir outro. Adicione um campo *T ou string e o objeto deixa de ser elegível a tiny mesmo que o struct continue pequeno.
Alocações tiny ainda escapam se você toma o endereço — o ganho é densidade de empacotamento e mark noscan, não placement na stack.
O que o compilador emite
Escape analysis não chama mallocgc diretamente. O compilador reduz alocações no heap para chamadas como newobject, makemap, makeslice ou mallocgc explícito no backend SSA (cmd/compile/internal/ssagen/ssa.go e afins). Cada uma carrega informação de tipo para o allocator escolher a size class certa e comportamento scan/noscan.
func newBox() *int {
n := new(int) // reduzido a alocação tipada no heap
*n = 1
return n
}
make([]T, len, cap) aloca o backing array no heap quando a slice escapa; o header de três words pode continuar na stack. new(T) sempre aloca T no heap — regra da linguagem, não quirk do allocator.
Fragmentação e retenção
Fragmentação interna — objeto menor que sua size class, ou large alloc arredondada para páginas — costuma ser aceitável perto de contenção de lock num heap global único.
Fragmentação externa — memória livre espalhada em spans que não atendem um pedido large novo — é tratada em conjunto com o GC: quando objetos morrem, spans ficam sweepable e slots voltam às free lists de mcache / mcentral. Até o sweep rodar, memória continua mapeada. O próximo artigo cobre mark tricolor e sweep; aqui o relevante é que liberar não é free(3) — slots recuperados são reutilizados pela mesma size class antes de páginas voltarem ao SO (e páginas raramente voltam ao SO na hora).
Ponteiros longevos para muitas size classes mantêm spans inteiros vivos mesmo que só um objeto no span esteja vivo. É retenção em nível de span, não leak no sentido Go, mas aparece como platô de heap em profiles.
sync.Pool e o allocator
sync.Pool não é allocator separado. Ele cacheia objetos já alocados por-P para pular mallocgc em hot paths. Com pool vazio, Pool.Get aloca normalmente; quando o GC roda, entradas do pool podem ser descartadas (a documentação de Pool avisa que podem ser limpas a qualquer momento).
var bufPool = sync.Pool{
New: func() interface{} {
b := make([]byte, 4096) // backing array no heap via makeslice
return &b
},
}
Pool reduz taxa de alocação; não muda provas de escape nem size classes. Meça antes de espalhar pools — eles acrescentam retenção e atrito de API.
Observando o comportamento do allocator
Heap profiles apontam para mallocgc e às vezes refill de mcache / mcentral:
go test -bench=. -benchmem ./...
go test -memprofile=mem.prof -bench=BenchmarkHandler ./...
go tool pprof -http=:8080 -alloc_space mem.prof
-alloc_space mostra onde bytes foram alocados ao longo do tempo; -alloc_objects conta eventos de alocação. Stack plana em mallocgc com sua função um frame acima significa que o compilador emitiu tráfego de heap naquele call site — combine com -gcflags="-m" do artigo de Memória para ver o porquê.
Para tamanho vivo do heap (retido, não alloc cumulativo), use -heap / inuse_space no pprof, ou:
GODEBUG=gctrace=1 ./myapp
schedtrace não detalha tiers do allocator; se suspeitar de lock em mcentral, CPU profile com mutex profiling:
go test -mutexprofile=mutex.prof -bench=.
go tool pprof -http=:8080 mutex.prof
Allocator vs stack: quando cada caminho ganha
| Situação | Caminho típico |
|---|---|
| Variável local, endereço não escapa | Stack (sem mallocgc) |
Retorno de *T, captura em closure, go |
Heap via mallocgc |
make(map...), backing store de slice que cresce |
Heap |
| Struct noscan pequeno no heap | Tiny ou small size class |
| Buffer > 32 KiB | Span large do mheap |
Reduzir trabalho do allocator significa reduzir escapes primeiro — escolher entre size classes é ajuste de segunda ordem. Escolher retorno por valor vs ponteiro ganha de tentar encaixar campos numa classe menor.
Knobs de tuning (e o que não existe)
Go não expõe MALLOC_ARENA_MAX estilo jemalloc nem tabela de size class configurável. Alavancas úteis que tocam alocação indiretamente:
- Menos escapes — compilador
-m, desenho de API GOGC— controla ritmo do GC, não roteamento do malloc; afeta quanto tempo spans liberados ficam antes do sweepGOMAXPROCS— maisPs → maismcachesdebug.SetMemoryLimit(Go 1.19+) — teto soft que faz o runtime devolver memória ao SO com mais agressividade sob pressão; não muda seleção de classe por alocação
Não espere flag para "desligar" o tiny allocator ou trocar por malloc do sistema para objetos normais do heap Go — o GC assume o layout de spans.
Conclusão
O heap allocator do Go roteia mallocgc por size classes e spans: alocações small quentes vêm do mcache do P atual sem locks; refills batem em mcentral; páginas novas e objetos large passam pelo mheap. A escape analysis do artigo de Memória decide se você aloca; esta camada decide quão rápido e com quanta fragmentação.
Próximo na série é Garbage Collector — como mark e sweep reclamam esses spans, o que write barriers custam e o que GOGC de fato controla.