Go Internals - Memória
Introdução
Em Go Internals - Scheduler vimos que toda goroutine (G) carrega sua própria stack. Este artigo cobre onde essa stack vive, como ela cresce e o que acontece quando um valor não pode ficar ali.
Partimos do source do Go 1.23 — principalmente src/runtime/stack.go e o pass de escape em cmd/compile/internal/escape/ — e ligamos código do dia a dia (locais, ponteiros, closures, go) a decisões concretas de layout. O assunto é a fronteira stack–heap: quem dona o lifetime de um valor e como a toolchain decide.
Stack vs heap: dois lifetimes
Programas Go usam dois lugares principais para armazenar objetos:
| Stack (por goroutine) | Heap (processo inteiro) | |
|---|---|---|
| Lifetime | Ligado ao stack frame da goroutine | Até não haver ponteiro vivo referenciando (GC reclama) |
| Custo de uso | Bump pointer no frame; sem scan de GC na alocação em si | Allocator + trabalho eventual de GC |
| Quem decide o placement | Escape analysis do compilador (na maior parte) | Compilador quando o valor escapa |
| Concorrência | Cada G tem stack própria; sem compartilhamento |
Compartilhado; exige sincronização ou imutabilidade |
| Tamanho típico | Kilobytes por goroutine, cresce sob demanda | Limitado por memória virtual e política de GC |
flowchart TB
subgraph G["Goroutine G"]
SF1["stack frame: f()"]
SF2["stack frame: g()"]
SF1 --> SF2
end
subgraph heap["Heap do processo"]
H1["*T escapado"]
H2["env de closure"]
H3["backing array de slice"]
end
SF2 -->|"escape de ponteiro"| H1
SF1 -->|"go / closure"| H2
SF2 -->|"append cresce além da stack"| H3
Tirar o endereço de um local não aloca no heap automaticamente. A escape analysis pergunta se esse endereço pode sobreviver ao frame. Se o ponteiro nunca sai da função, o valor pode ficar na stack mesmo com &x.
Nenhum lado ganha só em velocidade. Uso de stack troca com pressão no allocator e tracing de GC; uso de heap troca com cópia de stack no crescimento e footprint de memória por goroutine.
Stacks de goroutine no Go 1.23
Toda G executável tem um segmento de stack que o runtime gerencia em stack.go. Goroutines novas começam pequenas — da ordem de 2 KiB em plataformas 64-bit nas versões recentes do Go — e isso explica por que spawnar um número enorme de goroutines é viável. A maioria nunca precisa de stack de megabyte.
Desde Go 1.13, o crescimento copia a stack inteira para uma região contígua maior em vez de encadear segmentos. Isso simplifica o GC e o scan de stack; o custo aparece quando o crescimento acontece. Stacks têm tamanho máximo (1 GiB em 64-bit); ultrapassar dispara erro fatal de stack overflow, não corrupção silenciosa. O prólogo de cada função compara o stack pointer com um slot de guard; cruzar o guard dispara crescimento ou tratamento de overflow antes de sobrescrever memória adjacente.
O caminho go worker() do artigo de Scheduler aloca ou reutiliza uma G com esse mapeamento inicial. Código de usuário roda nessa stack até a goroutine terminar e o runtime reciclar a G, muitas vezes mantendo a stack já crescida para reuso.
func main() {
go func() {
var buf [128]byte // provavelmente na stack desta goroutine
_ = buf
}()
}
Cadeias de chamada profundas e locais grandes no frame aumentam o uso de stack e podem disparar morestack com mais frequência. Para dados de vida curta isso ainda é mais barato que alocação no heap mais trabalho de GC, mas não é grátis.
Como funciona o crescimento de stack
Quando uma função precisa de mais stack do que o segmento atual permite, o código compilado não chama malloc. Ele bate num stack check inserido pelo compilador; na falha, chama o caminho morestack do runtime, que eventualmente executa newstack em stack.go.
sequenceDiagram
participant Fn as função compilada
participant MS as morestack
participant NS as newstack
participant Sched as scheduler
Fn->>Fn: SP perto de stackguard
Fn->>MS: stack check falha
MS->>NS: aloca stack maior
NS->>NS: copia frames + corrige ponteiros
NS->>Fn: retoma na nova stack
Note over Sched: outras Gs podem rodar durante a cópia no mesmo M
Se frames contêm ponteiros para outros slots da stack ou para objetos no heap, o runtime ajusta esses ponteiros após a realocação. Crescimento de stack é código de runtime por esse motivo — não um realloc simples.
O crescimento tipicamente dobra o tamanho da stack até o frame caber (detalhes de implementação variam; a invariante é push/pop amortizado O(1) para profundidade limitada). Quando uma goroutine sai de recursão profunda e fica ociosa, Go nem sempre encolhe a stack na hora. O runtime pode reter o mapeamento maior para reuso na mesma G, trocando memória por menos ciclos de cópia.
flowchart TD
A["entrada da função"] --> B{"SP < stackguard?"}
B -->|não| C["executa corpo da função"]
B -->|sim| D["morestack"]
D --> E{"tamanho necessário > limite?"}
E -->|sim| F["fatal: stack overflow"]
E -->|não| G["newstack: aloca + copia"]
G --> C
C --> H["return / hooks de shrink"]
Se você perfilar um app com recursão pesada, tempo em runtime.newstack é crescimento de stack, não alocação no heap.
Escape analysis: o compilador decide
Se uma variável vive na stack ou no heap é decidido em compile time pela escape analysis em cmd/compile/internal/escape/. O runtime não promove variável de stack para heap no meio da execução; ou o compilador emitiu alocação no heap, ou não emitiu.
A análise constrói um grafo de escape: atribuições, chamadas, returns e closures adicionam arestas. Se um ponteiro para um local pode chegar a um ponto depois que o frame morre — return para caller, store em global, send em channel, captura por goroutine — o valor escapa e o compilador insere alocação no heap.
go build -gcflags="-m" ./...
-m imprime decisões de escape; -m -m adiciona detalhe. Rode em pacote pequeno enquanto aprende — a saída fica ruidosa em módulos grandes.
Fica na stack
func sum(a, b int) int {
x := a + b
return x
}
Nenhum ponteiro sai do frame; x é só stack.
func swap(p, q *int) {
*p, *q = *q, *p
}
Ponteiros entram do caller; locais que não vazam ficam na stack.
Escapa para o heap
Retornar ponteiro para local é o caso clássico:
func newInt() *int {
n := 42
return &n // escapa: caller segura ponteiro após o return
}
O compilador aloca n no heap porque o *int retornado sobrevive ao frame de newInt.
Guardar valor concreto em interface{} frequentemente força alocação no heap — a word da interface pode apontar para dados cujo lifetime o compilador não consegue limitar:
func printAny(v interface{}) {
fmt.Println(v)
}
func main() {
printAny(3) // caminhos de interface são hot spots comuns de escape
}
Closures iniciadas com go tipicamente alocam variáveis capturadas no heap porque a nova G pode outlive o frame que a criou:
func main() {
x := 10
go func() {
fmt.Println(x) // x escapa
}()
}
Maps e slices retornados de uma função seguem a mesma regra. make(map...) sempre usa estruturas com backing no heap; retornar o map mantém esse store vivo:
func cache() map[string]int {
m := make(map[string]int)
m["k"] = 1
return m
}
Casos flow-sensitive
func maybeEscape(debug bool) *[1]int {
var arr [1]int
if debug {
return &arr
}
return nil
}
Escape analysis é flow-sensitive: maybeEscape ainda escapa arr porque um branch retorna seu endereço.
Objetos grandes na stack — arrays acima de um threshold interno — podem ir para o heap mesmo sem vazamento de ponteiro, para evitar frames enormes e cópias caras de stack. Thresholds mudam entre releases; -gcflags="-m" é autoritativo para sua toolchain.
Lendo saída de -m
Em exemplo mínimo:
package main
func leak() *int {
n := 7
return &n
}
func main() {
_ = leak()
}
go build -gcflags="-m" -o /dev/null .
# ./main.go:5:2: moved to heap: n
# ./main.go:5:9: &n escapes to heap
Cada linha nomeia a variável, o motivo (moved to heap / escapes to heap) e muitas vezes o sink (return, channel, global). Ao otimizar, corrija o caminho de vazamento — return por valor, passe buffers para dentro, use sync.Pool só depois de medir alocação — em vez de remover *T ao acaso.
Sintoma em -m |
Direção provável de correção |
|---|---|
escapes to heap via return |
Retornar tipo valor em vez de ponteiro |
escapes to heap via go func |
Reduzir captura; passar args por valor |
moved to heap: ... array grande |
Dividir buffer; usar array na stack com tamanho em compile time |
... flows to heap via interface{} |
API concreta ou generics para evitar boxing |
Se o compilador não prova que um valor é seguro na stack, aloca. Correção vem antes de placement na stack.
Stack maps e roots do GC
Quando o garbage collector roda, ele precisa achar todos os ponteiros vivos, inclusive os guardados em stacks de goroutine. O compilador emite stack maps que são metadados descrevendo quais slots da stack guardam ponteiros em cada safepoint. O runtime usa isso no scan de stack em stack.go.
flowchart LR
subgraph compile["Compile time"]
SSA["SSA + escape"]
SM["stack maps"]
SSA --> SM
end
subgraph runtime["GC do runtime"]
STK["escaneia stacks de goroutine"]
HEAP["rastreia objetos no heap"]
SM --> STK
STK --> HEAP
end
Um ponteiro num local da stack mantém o objeto referenciado no heap vivo enquanto aquele frame existir. Uma função longa que segura *BigStruct num local estende retenção do GC mesmo raramente dereferenciando o ponteiro.
Preempção cooperativa e assíncrona (coberta no artigo de Scheduler) para goroutines em safepoints onde os stack maps são válidos. Corrompa o layout da stack e as premissas do GC quebram junto.
Headers de slice, string e map
Vários tipos built-in são headers pequenos apontando para dados no heap. Escape analysis costuma se importar com o backing store, não com as words do header que podem ficar na stack.
Um slice são três words (ponteiro, len, cap) na stack; o backing array pode escapar no primeiro append que excede capacity:
func grow() []int {
s := make([]int, 0, 2)
for i := 0; i < 100; i++ {
s = append(s, i)
}
return s
}
Uma string são duas words (ponteiro, length); os bytes ficam no heap ou em dados estáticos read-only para literais. O header de um map pode ficar na stack, mas buckets e cadeias de overflow alocam no heap depois do make.
Por isso "só tenho um slice na stack" ainda pode significar megabytes no heap. O artigo Built-in Types vai entrar nos layouts de slice, string e hmap; por ora, o relevante é que escape analysis e stack maps se aplicam às words de ponteiro que o GC precisa seguir.
Stacks e o scheduler
Memória e scheduling compartilham a abstração G. Um go cria uma G runnable com mapeamento inicial de stack. Enquanto a G está bloqueada, essa stack permanece mapeada. Quando a G termina, o runtime recicla a struct G e sua stack; objetos no heap que a goroutine referenciou ainda precisam do GC.
Preempção em safepoints também depende de stack maps válidos para root scan — a mesma G liga eventos de scheduling a metadata de memória.
Vazamento de goroutine vaza estado do scheduler e memória de stack, mais qualquer grafo escapado no heap que a goroutine segura. Isso é pressão em run queue e memória virtual, não só problema de GC:
func leakG() {
for {
go func() {
select {} // G nunca morre; stack permanece mapeada
}()
}
}
Alocação no heap: quando é correta, quando é acidental
Alocação no heap é a escolha certa para builders retornados, caches compartilhados, dados que outlive a goroutine da requisição, e maps ou slices compartilhados entre goroutines. O erro é escape acidental: retornar *T para struct pequena em toda chamada, logar via interface{} em call sites quentes, disparar go em loop apertado com variáveis capturadas, ou manter locais grandes [N]byte que o compilador move para o heap ou que forçam crescimento repetido de stack.
sync.Pool pode reduzir taxa de alocação, mas não muda provas de escape — o pool ainda guarda valores interface{} ou ponteiros.
Observando comportamento de memória
Comece com relatórios de escape no menor reprodutor que conseguir escrever:
go build -gcflags="-m -m" ./mypackage
Para caminhos quentes, confirme zero alocações em benchmarks:
func BenchmarkNoEscape(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = sum(1, 2)
}
}
Heap profiles mostram onde alocações acontecem — muitas vezes mallocgc — enquanto -m mostra por que o compilador emitiu:
go test -memprofile=mem.prof -bench .
go tool pprof -http=:8080 mem.prof
runtime/trace (do artigo de Scheduler) ajuda a correlacionar Gs longevas com stacks retidas. GODEBUG=gctrace=1 não explica decisões de escape, mas mostra se o grafo vivo no heap cresce junto com alocações escapadas — sinal útil antes do artigo Garbage Collector.
Conclusão
Armazenamento na stack é por goroutine e escopado ao frame; o runtime a cresce por cópia em stack.go. Armazenamento no heap é compartilhado, gerenciado pelo GC e escolhido em compile time pela escape analysis. O scheduler roda seu código em stacks; o compilador emite stack maps para o GC achar ponteiros onde quer que estejam.
Próximo na série é Allocator — como mallocgc roteia tamanhos por mcache, mcentral e mheap.