Introdução
Esse é um mito extremamente comum em code review de Go, inclusive em times experientes.
O roteiro é quase sempre o mesmo:
- alguém roda um benchmark
- vê uma diferença de
ns/op - conclui que função anônima é mais lenta
- abre um PR trocando closures por funções nomeadas
Na maioria dos casos, o culpado apontado é este aqui:
func(v int) int { return v + 1 } E quase sempre o culpado é outro.
TL;DR
- Função anônima não é mais lenta. Medi: 1,614 ns/op nas duas versões, diferença de 0%
- Closure não significa heap allocation. Capturar variável pode ficar 100% na stack
- Dos 6 cenários que medi, só 1 aloca, e é 3,2× mais lento
- O único que aloca é a closure criada a cada operação e que sobrevive à função que a criou
-
ns/opé ruído.B/opeallocs/opsão comportamento - O caso mais caro nem aparece no benchmark: closure que segura um objeto grande vivo (medi 100 MB presos por 1 byte usado)
Neste post você vai entender
- O que realmente custa em uma closure
- Como provar com benchmark, escape analysis e
pprof - Por que
ns/opengana e o que olhar no lugar - O caso de retenção de memória que
allocs/opnão mostra - Quando usar função anônima sem medo, e quando investigar
Todo o código está no fim do post. Você reproduz com um comando.
O problema
O mito em uma linha:
função anônima = heap allocation = lento Nenhuma das duas igualdades é verdadeira.
Compare:
// nomeada func addOne(v int) int { return v + 1 } // anônima func(v int) int { return v + 1 }(42) O compilador enxerga a expressão inteira na segunda versão. Ele inlina as duas.
Mesmo código de máquina. Mesmo custo.
O custo não vem da sintaxe. Vem de outra coisa.
O que realmente custa
Uma função normal recebe tudo pelos parâmetros:
func addOne(v int) int { return v + 1 } Uma closure carrega contexto junto:
offset := 10 fn := func(v int) int { return v + offset } offset não chegou por parâmetro. A função precisa lembrar dele.
É como se ela carregasse uma mochila:
Closure ┌─────────────────────┐ │ código da função │ │ contexto capturado │ │ └── offset = 10 │ └─────────────────────┘ Isso ainda não significa heap.
A pergunta que decide tudo é:
Por quanto tempo esse contexto precisa existir?
Se ele morre junto com a função atual, fica na stack. Se sobrevive, vai para o heap.
Quem decide isso é o escape analysis do compilador:
captura variável │ ▼ ela escapa? / \ não sim │ │ ▼ ▼ stack heap Então:
capturar variável ≠ ir para o heap Vamos provar.
Provando com uma POC
Seis cenários, do mais simples ao mais caro:
package closurebench import "testing" var ( sinkInt int sinkFn func(int) int ) func addOne(v int) int { return v + 1 } func makeAdder(delta int) func(int) int { return func(v int) int { return v + delta } } // 1. função nomeada, chamada direta func BenchmarkNamedDirect(b *testing.B) { for b.Loop() { sinkInt = addOne(42) } } // 2. função anônima executada na hora func BenchmarkAnonymousImmediate(b *testing.B) { for b.Loop() { sinkInt = func(v int) int { return v + 1 }(42) } } // 3. closure que captura, mas não sobrevive ao loop func BenchmarkCapturedNonEscaping(b *testing.B) { delta := 1 for b.Loop() { fn := func(v int) int { return v + delta } sinkInt = fn(42) } } // 4. closure criada UMA vez e reutilizada func BenchmarkClosureReused(b *testing.B) { fn := makeAdder(1) for b.Loop() { sinkInt = fn(42) } } // 5. chamada através de um function value func BenchmarkIndirectFunctionValue(b *testing.B) { sinkFn = addOne for b.Loop() { sinkInt = sinkFn(42) } } // 6. closure NOVA a cada operação, que sobrevive func BenchmarkEscapingClosureCreation(b *testing.B) { delta := 1 for b.Loop() { sinkFn = makeAdder(delta) delta++ } } Rodando:
go test -bench=. -benchmem -count=10 Resultado (go1.27.1, Apple M4, resumido com benchstat):
| # | Cenário | ns/op | B/op | allocs/op |
|---|---|---|---|---|
| 1 | nomeada direta | 1,614 | 0 | 0 |
| 2 | anônima imediata | 1,614 | 0 | 0 |
| 3 | closure sem escape | 1,889 | 0 | 0 |
| 4 | closure reutilizada | 1,617 | 0 | 0 |
| 5 | function value | 1,695 | 0 | 0 |
| 6 | closure escapando | 5,160 | 16 | 1 |
Olhe a coluna allocs/op.
Cinco zeros e um um.
Atenção: o número absoluto engana
Antes de interpretar, uma armadilha que quase me pegou.
Repeti a medição na mesma máquina, mesmo binário, 40 minutos depois. A única diferença: o notebook saiu da tomada e entrou em low power mode.
| Cenário | Na tomada | Na bateria | Razão A | Razão B |
|---|---|---|---|---|
| nomeada direta | 1,614 | 3,358 | 1,00 | 1,00 |
| anônima imediata | 1,614 | 3,358 | 1,00 | 1,00 |
| closure sem escape | 1,889 | 3,646 | 1,17 | 1,09 |
| closure reutilizada | 1,617 | 3,358 | 1,00 | 1,00 |
| function value | 1,695 | 3,415 | 1,05 | 1,02 |
| closure escapando | 5,160 | 9,485 | 3,20 | 2,82 |
Todos os tempos dobraram.
B/op e allocs/op não mudaram em nenhuma linha.
A regra que fica:
ns/op → depende de CPU, clock, energia, carga não é reproduzível B/op → é comportamento gerado pelo compilador allocs/op é reproduzível Compare razões, nunca nanossegundos.
A tabela não é um ranking
Essa é a leitura errada mais fácil de fazer.
Dá vontade de ordenar os seis do mais rápido ao mais lento. Mas os cinco primeiros estão empatados dentro do ruído:
┌─────────────────────────────────┐ │ NÃO ALOCA │ │ │ │ nomeada direta 1,00× │ │ anônima imediata 1,00× │ │ closure reutilizada 1,00× │ │ function value 1,05× │ │ closure sem escape 1,17× │ └─────────────────────────────────┘ ⬇ salto ┌─────────────────────────────────┐ │ ALOCA │ │ │ │ closure escapando 3,20× │ └─────────────────────────────────┘ A separação não é gradual. É binária: aloca ou não aloca.
Na segunda amostra, o function value caiu de 1,05× para 1,02×. A closure sem escape, de 1,17× para 1,09×.
A única diferença que sobreviveu à troca de ambiente foi a alocação.
Provando com escape analysis
Não precisa acreditar em mim. Pergunte ao compilador:
go test -run='^$' -gcflags='-m=2' ./... A saída do código acima inclui exatamente estas linhas:
closure_bench_test.go:14:16: makeAdder capturing by value: delta (width=8) closure_bench_test.go:14:6: can inline makeAdder with cost 17 closure_bench_test.go:15:9: func literal escapes to heap ← cenário 6 closure_bench_test.go:38:9: func literal does not escape ← cenário 3 As duas últimas linhas são o post inteiro.
Mesma sintaxe. Mesmo tipo de closure. Destinos opostos.
O que muda entre elas não é como você escreveu. É se o contexto precisa sobreviver.
Provando com pprof
O benchmark diz quanto. O pprof diz onde.
Coletando:
go test -run='^$' \ -bench=BenchmarkEscapingClosureCreation \ -benchmem \ -memprofile=mem.out \ -o closurebench.test go tool pprof -alloc_space -top closurebench.test mem.out O caminho é este:
benchmark roda │ │ -memprofile=mem.out ▼ ┌────────────────────┐ │ mem.out │ cada alocação + seu stack trace └────────────────────┘ │ │ go tool pprof ▼ ┌──────────────────────────────────────┐ │ -alloc_space quantos BYTES │ ← allocation rate │ -alloc_objects quantos OBJETOS │ │ -inuse_space o que continua VIVO │ ← live heap └──────────────────────────────────────┘ │ ▼ linha exata do código Guarde a diferença entre a primeira e a última flag. Vamos precisar dela daqui a pouco.
A saída:
Type: alloc_space Showing nodes accounting for 3.34GB, 99.94% of 3.34GB total flat flat% cum cum% 3.34GB 99.94% 3.34GB 99.94% closurebench.makeAdder (inline) 0 0% 3.34GB 99.94% closurebench.BenchmarkEscapingClosureCreation E em objetos:
Type: alloc_objects 224136539 100% 224136539 100% closurebench.makeAdder (inline) Visualmente:
onde alocou quanto ┌────────────────────────────┬──────────┐ │ makeAdder (inline) │ 3.34GB │ ████████████ │ todo o resto │ ~2MB │ ▏ └────────────────────────────┴──────────┘ Fecha a conta: 224.136.539 objetos × 16 B = 3,34 GB.
Cem por cento da alocação em uma linha de código, exatamente onde a closure escapa.
E aqui aparece o ponto sobre frequência. Dezesseis bytes parecem irrelevantes. Multiplicados por 224 milhões, viram 3,34 GB que o allocator e o GC tiveram que processar.
custo × frequência Se preferir navegar em vez de ler:
go tool pprof -http=:8080 closurebench.test mem.out Abre flame graph e visão por linha no navegador.
O caso que o benchmark não mostra: live heap
Esse é o mais caro de todos. E ele não aparece em allocs/op.
Considere:
func firstByte(buf []byte) func() byte { return func() byte { return buf[0] } } Agora:
buf := make([]byte, 100*1024*1024) // 100 MB fn := firstByte(buf) A closure usa 1 byte.
Mas ela capturou buf. E o slice referencia o backing array inteiro:
closure │ ▼ slice │ ▼ 100 MB backing array Enquanto a closure estiver alcançável, os 100 MB também estão. O GC não pode coletar.
Escrevi um teste que mede o heap vivo depois de um runtime.GC(), com a closure ainda viva:
live heap retido: 104.779.808 bytes Cem megabytes presos por causa de um byte.
A correção
Capture só o que você precisa:
// antes segura 100 MB func firstByte(buf []byte) func() byte { return func() byte { return buf[0] } } // depois segura 1 byte func firstByte(buf []byte) func() byte { first := buf[0] return func() byte { return first } } Mesmo teste, mesma medição:
live heap retido: -40.160 bytes Negativo porque o GC ainda limpou lixo residual. Na prática: zero.
E o compilador confirma:
retention_test.go:19:29: buf does not escape ANTES DEPOIS closure closure │ │ ▼ ▼ slice byte │ ▼ 100 MB Uma linha de código. De 100 MB para 1 byte.
É por isso que existem duas perguntas diferentes:
| Conceito | Pergunta | Flag do pprof |
|---|---|---|
| Allocation rate | quanta memória nova por segundo? | -alloc_space |
| Live heap | quanto continua alcançável? | -inuse_space |
O benchmark só enxerga a primeira.
E o caso é pior quando a closure captura algo como req *Request, que referencia User, Session, Headers, Buffer, Cache. Uma closure pequena mantendo um grafo inteiro vivo.
Function value: o custo não é a chamada indireta
Aqui eu esperava achar um vilão e não achei. Vale contar por quê.
A intuição diz que isto é mais caro:
var fn func(int) int = addOne sinkInt = fn(42) porque o compilador não conhece o destino direto.
Medido: 1,695 contra 1,614 ns/op. Cinco por cento que viraram dois na segunda amostra.
Testei também se o problema seria alvo imprevisível. Um benchmark chama sempre a mesma função; o outro alterna entre quatro:
| Benchmark | ns/op |
|---|---|
| alvo sempre o mesmo | 1,758 |
| quatro alvos em rodízio | 1,762 |
Idênticos. A indireção em si é quase de graça em CPU moderna.
O custo real é outro:
function value ↓ compilador não sabe o destino ↓ não consegue inlinar E isso só importa quando existe algo que valha a pena inlinar.
addOne é return v + 1. Não há o que ganhar. Troquei por um corpo maior, um loop de oito iterações:
| Chamada | ns/op |
|---|---|
| direta, inlinável | 2,57 |
| via function value | 3,51 |
Agora sim: 36% a mais.
Ou seja:
named function ≠ automaticamente rápida anonymous function ≠ automaticamente lenta O que importa é o que o compilador conseguiu gerar e o que ele deixou de conseguir.
Regra de ouro
Não otimize a sintaxe. Otimize o comportamento gerado.
Na prática, isso vira três perguntas:
- A closure escapa? (escape analysis responde)
- Ela aloca por operação? (benchmark responde)
- Ela segura algo grande vivo? (
-inuse_spaceresponde)
Se as três respostas forem não, não existe otimização a fazer.
Como investigar, na ordem certa
1. Benchmark
go test -bench=. -benchmem -count=10 Máquina ociosa e na tomada. Já mostrei o que acontece quando não é.
2. Escape analysis
go test -run='^$' -gcflags='-m=2' ./... Procure por escapes to heap, does not escape, capturing by value, can inline.
3. Memory profile
go test -bench=. -benchmem -memprofile=mem.out go tool pprof -alloc_space mem.out Onde estou alocando, em vez de onde eu acho que estou.
4. Compare antes e depois
go test -bench=. -count=10 > before.txt # ... sua mudança ... go test -bench=. -count=10 > after.txt benchstat before.txt after.txt O benchstat diz se a diferença é sinal ou ruído. Uma execução única não diz nada.
Use função anônima sem medo aqui
Callbacks:
slices.SortFunc(users, func(a, b User) int { return cmp.Compare(a.Age, b.Age) }) Handlers:
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) Goroutines:
go func() { process() }() Defer:
defer func() { cleanup() }() Nesses casos você ganha localidade, legibilidade e encapsulamento. E, pelos números acima, não paga nada por isso.
Investigue quando aparecerem juntos
closure + hot path + alta frequência + allocation Exemplo clássico:
for _, item := range millionsOfItems { fn := createProcessor(item) // closure nova a cada item queue = append(queue, fn) // e todas sobrevivem } Ou closures guardadas por muito tempo em slice, map, queue, channel ou variável global.
E, principalmente: closures que capturam estruturas grandes.
E aumentar o GOGC resolve?
Não.
GOGC maior GOGC menor ↓ ↓ GC menos frequente GC mais frequente mais memória menos memória É uma troca entre CPU e memória. É runtime tuning.
Não remove uma allocation desnecessária de um hot path.
E zerar todas as allocations?
Também não.
Uma operação que roda 10 vezes por minuto: 1 alloc/op não importa.
A mesma operação 5 milhões de vezes por segundo: muda tudo.
Performance precisa de contexto.
Comparativo final
| Situação | Preocupa? | Medido |
|---|---|---|
| Função anônima simples | não | idêntico à nomeada |
| Closure com 0 allocs/op | não | 1,00–1,17× |
| Closure criada uma vez e reutilizada | não | 1,00× |
| Function value em hot path | só se a função for grande | 1,05× aqui, 1,36× com corpo maior |
| Closure escapando a cada operação | sim | 3,20× + 16 B/op |
| Closure capturando objeto grande | sim | 100 MB retidos |
| Milhões de allocations por segundo | atenção alta | 3,34 GB no profile |
Checklist
Existe closure? ↓ Captura variável? ↓ Escapa? ............. gcflags=-m=2 ↓ Aloca por operação? . benchmark ↓ Está em hot path? ↓ Aparece no profile? . pprof ↓ Só então otimizar Se travou em qualquer etapa, pare. Não tem otimização ali.
Código da POC
Os 6 benchmarks, os testes que transformam a tabela em asserção, o teste de live heap e o script que roda benchmark + escape analysis + pprof:
git clone <REPO> cd <PASTA> ./run.sh Os seus números vão ser diferentes dos meus. Compare as razões.
E se você achar um cenário que contradiz algum destes resultados, me manda nos comentários, eu rodo.