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.
Top comments (0)