trilha

Fundamentos de Programação/01 - Fundamentos Duros/Semana 01 - Diagnóstico e o que Acontece por Baixo7 min

Stack vs Heap

Perguntas-guia
  • 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.

Por que as duas existem

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 de fmt.Println escapam
  • capturar em closure executada por go ou devolvida
  • tamanho desconhecido em compilação (make([]T, n) com n variá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 panicrecover 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


Parte de Semana 01 - Diagnóstico e o que Acontece por Baixo · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas