trilha

Fundamentos de Programação/01 - Fundamentos Duros/Semana 03 - Estruturas de Dados II e Recursão6 min

Pilha de Chamadas

Perguntas-guia
  • 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 é:

  1. ler a mensagem primeiro (o quê aconteceu)
  2. descer a pilha até a primeira linha que é código seu (onde você causou)
  3. 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)
O error de Go não carrega stack trace

Esta é 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:

  1. leia a linha de panic: — ela diz o quê
  2. pule os frames de runtime.*
  3. pare na primeira linha com o caminho do seu módulo
  4. olhe o estado da goroutine — em travamento, [chan receive] ou [semacquire] vale mais que qualquer linha
  5. 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.
  • error sem 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

Buscar

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