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/op e allocs/op sã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/op engana e o que olhar no lugar
  • O caso de retenção de memória que allocs/op nã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:

  1. A closure escapa? (escape analysis responde)
  2. Ela aloca por operação? (benchmark responde)
  3. Ela segura algo grande vivo? (-inuse_space responde)

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.