Voltar ao início

Go Internals - Memória

12 min de leitura
Cover Image for Go Internals - Memória
Lucas LemosLucas Lemos

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.