Fundamentos de Programação/01 - Fundamentos Duros/Semana 03 - Estruturas de Dados II e Recursão6 min
Pilha de Chamadas
- O que exatamente é empilhado a cada chamada de função?
- Por que stack overflow acontece, e qual a profundidade limite na sua linguagem?
- Sua linguagem faz otimização de chamada em cauda? (a maioria não faz)
- Como ler um stack trace e ir direto à linha que importa? (item da autoavaliação)
Conceito
A pilha de chamadas é a estrutura que permite que uma função volte para quem a chamou. Cada chamada empilha um frame (registro de ativação) contendo:
- os argumentos e os valores de retorno
- as variáveis locais que não foram promovidas a registradores
- o endereço de retorno — para onde continuar quando esta função terminar
- referência ao frame anterior — é o que permite reconstruir a cadeia
Note que o frame guarda o endereço de retorno, e não a função chamadora. Isso é o suficiente: seguindo os endereços de retorno de frame em frame, você reconstrói toda a cadeia de chamadas. É literalmente isso que um stack trace é — a pilha, impressa.
Por que estoura: a região é limitada e cada chamada ativa consome um frame. Profundidade × tamanho do frame > limite. E o tamanho do frame varia: uma função com muitos locais grandes consome mais por nível, então a profundidade máxima não é um número fixo da linguagem — depende do que suas funções declaram.
Ler um stack trace bem é uma habilidade, e o erro é olhar a linha mais interna. A linha de cima costuma estar dentro da stdlib ou de uma biblioteca, e não é onde está o seu bug. O procedimento é:
- ler a mensagem primeiro (o quê aconteceu)
- descer a pilha até a primeira linha que é código seu (onde você causou)
- usar os frames acima só para entender como chegou lá
Inlining atrapalha: o compilador remove chamadas de funções pequenas, colando o corpo no chamador. Isso apaga frames do trace — a função existe no seu código e não aparece na pilha.
Em Go
O frame de uma função Go contém, além do usual, a lista de defer quando eles não
podem ser resolvidos estaticamente. Desde o Go 1.14 os defer comuns são open-coded
(inseridos direto no fluxo, quase sem custo); os dentro de laço ainda vão para uma
lista no frame.
A stack da goroutine é elástica, e isso tem uma consequência que vale entender: ao
crescer, o runtime aloca uma stack nova, copia tudo e reajusta todos os ponteiros que
apontavam para a stack antiga. Para isso ser possível, o runtime precisa saber
exatamente onde estão os ponteiros — e é por isso que Go tem informação precisa de tipos
em runtime, e por isso você não consegue fazer aritmética de ponteiro fora de unsafe.
Anatomia de um trace de Go:
goroutine 1 [running]:
main.divide(0x0)
/app/main.go:12 +0x1d
main.processa(...)
/app/main.go:18
main.main()
/app/main.go:23 +0x25
goroutine 1 [running]— id e estado (running,chan receive,select,IO wait,semacquire). Em deadlock, o estado é a informação mais valiosa.+0x1d— deslocamento na função; útil quando a linha é ambígua.main.processa(...)com(...)e sem deslocamento significa que os argumentos foram omitidos, tipicamente por inlining.
Ferramentas:
| Para | Use |
|---|---|
| trace da goroutine atual | debug.PrintStack() |
trace como []byte |
runtime.Stack(buf, false) |
| todas as goroutines num panic | GOTRACEBACK=all |
| stack de todas em runtime | runtime.Stack(buf, true) |
error de Go não carrega stack traceEsta é a diferença prática mais importante em relação a Java, Python ou Rust: um
error em Go é só um valor com uma string. Ele não sabe onde foi criado.
Se você recebe erro: connection refused de três camadas acima, não existe trace
para consultar. É por isso que envolver o erro com contexto não é boa educação — é a
única informação de origem que vai existir:
return fmt.Errorf("carregando usuário %d: %w", id, err)
O %w preserva o erro original para errors.Is / errors.As, e o prefixo constrói
à mão o que outras linguagens te dão automaticamente.
Respostas às perguntas-guia
1. O que exatamente é empilhado a cada chamada de função?
Conceito: argumentos, retornos, locais que não caibam em registradores, endereço de retorno e o encadeamento para o frame anterior.
Em Go: mais a lista de defer quando não são open-coded. E desde o Go 1.17 os
argumentos passam por registradores quando possível (register-based ABI), o que
reduz o frame — e é parte do motivo de traces às vezes mostrarem valores de argumento
incorretos ou omitidos.
2. Por que stack overflow acontece, e qual a profundidade limite na sua linguagem?
Conceito: profundidade × tamanho do frame excede a região reservada.
Em Go: 2 KB iniciais, crescendo por cópia até 1 GB em 64 bits
(debug.SetMaxStack ajusta). A profundidade concreta depende do tamanho dos seus
frames — não é um número da linguagem. E o estouro é fatal error, não panic:
recover não pega.
3. Sua linguagem faz otimização de chamada em cauda?
Conceito: TCO reusa o frame quando a chamada recursiva é a última operação.
Em Go: não, e é decisão deliberada — TCO destrói o stack trace, e Go optou por priorizar depuração. Consequência: não existe recursão "de graça" em Go.
4. Como ler um stack trace e ir direto à linha que importa?
Conceito: mensagem primeiro, depois desça até a primeira linha do seu código.
Em Go, concretamente:
- leia a linha de
panic:— ela diz o quê - pule os frames de
runtime.* - pare na primeira linha com o caminho do seu módulo
- olhe o estado da goroutine — em travamento,
[chan receive]ou[semacquire]vale mais que qualquer linha - se faltarem frames ou os argumentos estiverem estranhos, suspeite de inlining e
recompile com
-gcflags='-l'para desligá-lo
Trade-offs
Do conceito:
- Stack de tamanho fixo é previsível e desperdiça (ou estoura). Elástica se adapta e paga cópia.
- Inlining compra performance e cobra fidelidade do trace.
- TCO compra recursão sem limite e cobra o histórico de chamadas.
Em Go:
- Stack elástica de 2 KB é o que viabiliza centenas de milhares de goroutines — o preço é a maquinaria de cópia e reajuste de ponteiros.
- Sem TCO: recursão custa memória, e em troca o trace é fiel.
errorsem trace é mais leve e mais rápido (nenhuma captura de pilha na criação do erro, que em Java é o custo dominante de exceções) e transfere para você o trabalho de registrar o contexto.
Exemplo prático
package main
import (
"errors"
"fmt"
"runtime"
"runtime/debug"
"strings"
)
func nivel3() { debug.PrintStack() }
func nivel2() { nivel3() }
func nivel1() { nivel2() }
// Erro em Go não carrega origem: o contexto é construído à mão com %w
var ErrNaoEncontrado = errors.New("não encontrado")
func buscaNoBanco(id int) error {
return fmt.Errorf("consulta usuários id=%d: %w", id, ErrNaoEncontrado)
}
func servico(id int) error {
if err := buscaNoBanco(id); err != nil {
return fmt.Errorf("carregando perfil: %w", err)
}
return nil
}
// Profundidade real da pilha, medida
func profundidade() int {
pc := make([]uintptr, 1000)
return runtime.Callers(0, pc)
}
func desce(n int) int {
if n == 0 { return profundidade() }
return desce(n - 1)
}
func main() {
fmt.Println("--- stack trace, 3 níveis ---")
nivel1()
fmt.Println("\n--- erro com contexto acumulado ---")
err := servico(42)
fmt.Println(err)
fmt.Println("é ErrNaoEncontrado?", errors.Is(err, ErrNaoEncontrado))
fmt.Println("camadas:", strings.Count(err.Error(), ": "))
fmt.Println("\n--- profundidade cresce 1 por chamada ---")
for _, n := range []int{0, 10, 100} {
fmt.Printf("desce(%d) -> %d frames\n", n, desce(n))
}
}
A cadeia de erro impressa — carregando perfil: consulta usuários id=42: não encontrado — é o stack trace que Go não te dá de graça, escrito à mão. Cada %w é
um frame que você decidiu registrar.
Relacionado
- 5. Recursão — profundidade é consumo desta pilha
- 2. Stack vs Heap — semana 1, a stack elástica de 2 KB
- 4. Pilha — semana 2, a disciplina LIFO que isto instancia
- 5. Bisect — semana 8, quando o trace não basta e você precisa achar o commit
Parte de Semana 03 - Estruturas de Dados II e Recursão · 00 - MOC Fundamentos de Programação