Fundamentos de Programação/01 - Fundamentos Duros/Semana 01 - Diagnóstico e o que Acontece por Baixo7 min
Stack vs Heap
- O que vive na stack e o que vive na heap na sua linguagem?
- Quem libera cada uma — o escopo, o GC, ou você?
- Por que recursão profunda provoca stack overflow e um loop não?
- Isso é observável no seu dia a dia ou é detalhe de runtime?
Conceito
Stack e heap não são "dois lugares na memória". São duas estratégias de alocação com disciplinas de tempo de vida diferentes — e a diferença de disciplina é o que gera toda a diferença de custo.
Stack — disciplina LIFO. Alocar é incrementar um ponteiro; desalocar é decrementá-lo. Não existe busca, não existe fragmentação, não existe metadado. É literalmente a operação mais barata possível.
O preço é a restrição: os tempos de vida precisam ser aninhados. O que foi alocado depois tem que morrer antes. Um valor não pode sobreviver à função que o criou, porque a função que retorna leva o frame embora.
Heap — tempo de vida arbitrário. Aloca e libera em qualquer ordem, em qualquer
tamanho. Isso exige contabilidade: listas de blocos livres, classes de tamanho, e
alguém para decidir quando o valor morreu — você (free), contagem de
referências, ou um coletor que rastreia alcançabilidade.
A stack é ordens de magnitude mais barata, mas só sabe expressar tempo de vida aninhado. Todo valor que precisa sobreviver a quem o criou tem que ir pro heap. Essa é a regra inteira; o resto é detalhe de implementação.
A pergunta que separa as linguagens: quem decide? Três modelos:
| Modelo | Quem decide | Exemplos |
|---|---|---|
| Sintático | você, na chamada | C (malloc vs local), C++ (new vs objeto de stack) |
| Por tipo | a linguagem, pela categoria do tipo | Java, C# (primitivo na stack, objeto sempre no heap) |
| Por análise | o compilador, provando tempo de vida | Go, Rust, JVMs modernas com escape analysis |
Por que a stack de chamadas estoura: ela é uma região de tamanho limitado, e cada chamada ativa ocupa um frame. Um loop reusa um frame; recursão de profundidade N ocupa N frames. Recursão em cauda poderia reusar o frame — mas só se a linguagem implementar essa otimização, e a maioria não implementa.
O efeito que quase ninguém menciona: localidade. A stack está sempre quente em cache; objetos de heap ficam espalhados. Em código que percorre muitos valores pequenos, a diferença de cache costuma pesar mais que o custo de alocar.
Em Go
Go usa o modelo por análise, e leva ao extremo: você não tem nenhuma forma de escolher.
Escape analysis decide, em tempo de compilação. Não existe "new vai pro heap,
literal fica na stack" — new(T) pode ficar na stack e um literal pode ir pro heap.
A regra: se o compilador não consegue provar que o valor morre com a função, ele
escapa.
Causas comuns de escape:
- retornar ponteiro para variável local
- guardar ponteiro em algo que já está no heap
- passar para
any/interface que escapa — é por isso que os argumentos defmt.Printlnescapam - capturar em closure executada por
goou devolvida - tamanho desconhecido em compilação (
make([]T, n)comnvariável) ou grande demais
Quem libera: o retorno da função (stack) e o GC (heap) — mark-sweep
concorrente, tricolor, não-geracional e não-compactante. Você paga em CPU e latência,
nunca em free().
A stack de goroutine é dinâmica, e isso é a inovação real: começa com 2 KB e
cresce por cópia — o runtime aloca uma stack maior, copia tudo e reajusta os
ponteiros (contiguous stacks, desde Go 1.4). É o que torna centenas de milhares de
goroutines viáveis: uma thread do SO reserva megabytes de stack, uma goroutine reserva
2 KB. Teto padrão em 64 bits: 1 GB, ajustável com debug.SetMaxStack.
Go não faz tail-call optimization. Recursão em cauda empilha frame como qualquer outra.
Respostas às perguntas-guia
1. O que vive na stack e o que vive na heap na sua linguagem?
Conceito: na stack, o que tem tempo de vida aninhado no da função. No heap, o que sobrevive a ela. Não é sobre o tipo do valor — é sobre o tempo de vida dele.
Em Go: stack são parâmetros, retornos, locais que não escapam e os frames. Heap é
tudo que escapa. Go não tem a regra "tipo de valor na stack, tipo de referência no
heap" — um struct pode ir pro heap e um new() pode ficar na stack.
2. Quem libera cada uma — o escopo, o GC, ou você?
Conceito: stack sempre pelo escopo, em qualquer linguagem — é a definição da disciplina. Heap varia: você (C), contagem de referências (Swift, CPython), propriedade verificada em compilação (Rust), ou coletor de rastreio (Java, Go).
Em Go: escopo e GC. Você nunca. O único controle indireto é evitar que o valor escape.
3. Por que recursão profunda provoca stack overflow e um loop não?
Conceito: um frame por chamada ativa contra um frame reusado. A stack é limitada, então profundidade N consome memória proporcional a N.
Em Go: sem TCO, nem recursão em cauda escapa disso. Ao estourar:
runtime: goroutine stack exceeds 1000000000-byte limit
fatal error: stack overflow
fatal error, não panic — recover não pega. O processo morre.
4. Isso é observável no seu dia a dia ou é detalhe de runtime?
Conceito: observável em qualquer linguagem com GC, sempre pela mesma via — alocação gera pressão de coleta, e pressão de coleta aparece como latência. É a razão de existirem object pools em toda linguagem gerenciada.
Em Go, é a raiz de metade das otimizações: sync.Pool, strings.Builder em vez de
concatenação, e o resultado contraintuitivo de que passar struct por valor às
vezes ganha de passar ponteiro (não escapa, não aloca).
Ferramentas: go build -gcflags='-m', go test -bench . -benchmem (olhe
allocs/op), go tool pprof, GODEBUG=gctrace=1.
Trade-offs
Do conceito:
- Decisão sintática (C) dá controle total e transfere pra você a chance de errar o tempo de vida — use-after-free e leak são exatamente esse erro.
- Decisão por tipo (Java) é previsível e desperdiça: todo objeto no heap, mesmo o que morre na linha seguinte.
- Decisão por análise (Go) recupera a performance sem transferir o risco — e te tira o controle. Otimizar passa a ser convencer o compilador, e a decisão pode mudar de versão pra versão.
- GC de rastreio troca custo determinístico (
free) por custo amortizado e latência ocasional. Bom para throughput, ruim para cauda de latência.
Da escolha de Go:
- Stack inicial de 2 KB viabiliza goroutines em massa; o preço é a cópia no crescimento e o reajuste de ponteiros.
- Heap não-compactante evita pausa de compactação e aceita fragmentação.
- Passar ponteiro evita a cópia e frequentemente força escape — trocando uma cópia barata por uma alocação. Para struct pequena, valor costuma ganhar. Meça.
Exemplo prático
package main
import "fmt"
type Point struct{ X, Y int }
// escapa: o ponteiro sobrevive à função
func escapa() *Point {
p := Point{1, 2}
return &p
}
// NÃO escapa, apesar do new()
func naoEscapa() int {
p := new(Point)
p.X = 1
return p.X
}
func main() {
fmt.Println(escapa(), naoEscapa())
}
$ go build -gcflags='-m' . 2>&1 | grep -E 'heap|escape'
moved to heap: p <- o literal Point{} foi pro HEAP
new(Point) does not escape <- o new() ficou na STACK
Sem o grep a saída vem afogada em linhas can inline e inlining call to, que
são de outra análise. As linhas que interessam contêm heap ou escape.
O new() que fica na stack e o literal que vai pro heap, lado a lado — é o exemplo
que derruba a intuição vinda de C++ ou Java.
Estourando a stack de propósito (uma vez, para ver a mensagem):
func fundo(n int) int {
if n == 0 {
return 0
}
return 1 + fundo(n-1) // sem TCO: frame novo a cada chamada
}
func main() {
defer func() { fmt.Println("recover:", recover()) }() // NÃO vai rodar
fmt.Println(fundo(100_000_000))
}
Relacionado
- 6. Pilha de Chamadas — o que exatamente é um frame
- 5. Recursão — profundidade é consumo de stack
- 3. Passagem por Valor vs por Referência — cópia na stack vs alias no heap
- 4. Mutabilidade — cópia defensiva quase sempre significa alocação
Parte de Semana 01 - Diagnóstico e o que Acontece por Baixo · 00 - MOC Fundamentos de Programação