A regra mais importante de otimização de performance é também a mais frequentemente ignorada: nunca otimize sem antes medir. Código que parece lento pode estar em um caminho raramente executado. Código que parece eficiente pode estar alocando memória em escala invisível a olho nu.
Go oferece um ecossistema completo de profiling integrado ao toolchain — sem dependências externas, sem instrumentação manual invasiva. O pprof captura CPU, memória, goroutines, contention de locks e muito mais. O trace captura a execução em linha do tempo. Juntos, eles tornam a otimização uma atividade baseada em evidências.
Benchmarks: a base da otimização
Antes de perfilar um sistema completo, isole a função suspeita em um benchmark:
package processamento
import (
"strings"
"testing"
)
// Função candidata a otimização
func concatenarStrings(partes []string) string {
resultado := ""
for _, p := range partes {
resultado += p // alocação a cada iteração
}
return resultado
}
func concatenarComBuilder(partes []string) string {
var sb strings.Builder
sb.Grow(estimarTamanho(partes))
for _, p := range partes {
sb.WriteString(p)
}
return sb.String()
}
func estimarTamanho(partes []string) int {
total := 0
for _, p := range partes {
total += len(p)
}
return total
}
// Benchmarks
func BenchmarkConcatenarStrings(b *testing.B) {
partes := []string{"Go", " é", " uma", " linguagem", " eficiente"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
concatenarStrings(partes)
}
}
func BenchmarkConcatenarComBuilder(b *testing.B) {
partes := []string{"Go", " é", " uma", " linguagem", " eficiente"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
concatenarComBuilder(partes)
}
}
# Executar benchmarks com métricas de memória
go test -bench=. -benchmem -count=5 ./processamento/
# Saída medida com Go 1.27 num Ryzen 7 3700X (16 threads)
# BenchmarkConcatenarStrings-16 131 ns/op 72 B/op 4 allocs/op
# BenchmarkConcatenarComBuilder-16 49 ns/op 32 B/op 1 allocs/op
pprof: profiling de CPU
Integração em servidores HTTP
package main
import (
"net/http"
_ "net/http/pprof" // importação em branco registra os handlers automaticamente
)
func main() {
// Servidor principal
go func() {
mux := http.NewServeMux()
mux.HandleFunc("/", handlerPrincipal)
http.ListenAndServe(":8080", mux)
}()
// Servidor de profiling: só na interface local. Com ":6060", ele
// escutaria em todas as interfaces, expondo memória e código do processo
http.ListenAndServe("localhost:6060", nil) // usa DefaultServeMux com pprof registrado
}
Coletando perfis via HTTP
# Perfil de CPU por 30 segundos
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Perfil de alocações de memória
go tool pprof http://localhost:6060/debug/pprof/heap
# Goroutines ativas
go tool pprof http://localhost:6060/debug/pprof/goroutine
# Contention de mutex
go tool pprof http://localhost:6060/debug/pprof/mutex
# Bloqueios em channels, mutexes e select (block profile)
go tool pprof http://localhost:6060/debug/pprof/block
Os perfis mutex e block vêm desligados por padrão, porque têm custo. Sem ativá-los no início do programa, com runtime.SetMutexProfileFraction(5) e runtime.SetBlockProfileRate(1) (ou uma taxa maior em produção), os dois endpoints respondem normalmente, mas sem nenhuma amostra. Num teste com oito goroutines disputando um mutex, os dois perfis vieram vazios sem as chamadas, e com amostras depois delas.
Profiling em benchmarks
# Gera perfil de CPU durante benchmark
go test -bench=BenchmarkConcatenarStrings \
-cpuprofile=cpu.prof \
-memprofile=mem.prof \
-benchmem \
./processamento/
# Analisa o perfil de CPU
go tool pprof cpu.prof
# Analisa o perfil de memória
go tool pprof mem.prof
Comandos interativos do pprof
(pprof) top # top N funções por tempo de CPU
(pprof) top10 -cum # top 10 por tempo acumulado (inclui subfunções)
(pprof) list NomeFuncao # detalha linha a linha uma função específica
(pprof) web # abre visualização gráfica no navegador (requer graphviz)
(pprof) png # gera imagem PNG do grafo de chamadas
(pprof) svg # gera SVG interativo
Uma sessão real, com o perfil do benchmark de concatenarStrings (saída resumida):
$ go tool pprof cpu.prof
Type: cpu
Duration: 2.59s, Total samples = 3000ms (115.88%)
(pprof) top6
flat flat% sum% cum cum%
740ms 24.67% 24.67% 2240ms 74.67% runtime.concatstrings
350ms 11.67% 36.33% 350ms 11.67% runtime.memmove
230ms 7.67% 44.00% 2640ms 88.00% perf/processamento.concatenarStrings
...
(pprof) list concatenarStrings
ROUTINE ======================== perf/processamento.concatenarStrings
230ms 2.64s (flat, cum) 88.00% of Total
. . 9:func concatenarStrings(partes []string) string {
. . 10: resultado := ""
230ms 230ms 11: for _, p := range partes {
. 2.41s 12: resultado += p // alocação a cada iteração
. . 13: }
. . 14: return resultado
. . 15:}
A coluna flat é o tempo gasto na própria função; cum inclui as funções que ela chama. A linha 12 tem flat vazio e 2,41 s em cum: o custo está no runtime.concatstrings, que o += chama a cada volta.
pprof: visualização web integrada
# Interface web completa — mais útil que o modo interativo
go tool pprof -http=:8090 cpu.prof
Isso abre um servidor web com:
- Graph — grafo de chamadas com pesos visuais
- Flame Graph — visualização de flamegraph interativa
- Top — tabela ordenada por custo
- Source — código anotado com custos por linha
- Disasm — assembly anotado
O flamegraph é especialmente útil. A largura de cada barra é a fração das amostras em que a função estava na pilha, e a direção vertical é a profundidade das chamadas: no pprof, a raiz fica no alto, e cada função chamada aparece abaixo de quem a chamou. O tempo gasto na própria função é a parte da barra que não tem nada embaixo.
Profiling de memória em profundidade
package main
import (
"os"
"runtime"
"runtime/pprof"
)
func perfilarMemoria(caminho string) func() {
f, _ := os.Create(caminho)
return func() {
runtime.GC() // força GC para perfil mais preciso
pprof.WriteHeapProfile(f)
f.Close()
}
}
func main() {
encerrar := perfilarMemoria("mem.prof")
defer encerrar()
// Código a perfilar
processar()
}
Tipos de perfil de memória:
# Número de objetos alocados desde o início (inclui os já coletados)
go tool pprof -alloc_objects mem.prof
# Bytes alocados desde o início (inclui os já coletados)
go tool pprof -alloc_space mem.prof
# Objetos ainda vivos no momento da captura
go tool pprof -inuse_objects mem.prof
# Bytes ainda vivos no momento da captura (o padrão do perfil heap)
go tool pprof -inuse_space mem.prof
Trace: visualização temporal da execução
O trace captura eventos com timestamps precisos — goroutines criadas, GC pausas, chamadas de sistema, preempções:
package main
import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// Código a analisar
executarTarefas()
}
# Analisar o trace
go tool trace trace.out
O trace viewer exibe:
- Goroutine Analysis — tempo em cada estado (running, runnable, blocked, sleeping)
- GC pausas — duração e frequência
- Scheduler latency — tempo esperando para ser escalonado
- Syscall duration — tempo em chamadas de sistema
Trace em benchmarks
# Um pacote por vez: com ./... e mais de um pacote, o go test recusa
# com "cannot use -trace flag with multiple packages" (o mesmo vale
# para -cpuprofile e -memprofile)
go test -bench=BenchmarkProcessar -trace=trace.out ./processamento/
go tool trace trace.out
Identificando e corrigindo problemas comuns
Alocações excessivas: escape analysis
# Descobre o que escapa para o heap
go build -gcflags="-m" ./...
go build -gcflags="-m -m" ./... # mais detalhado
// O -m diz "&Ponto{...} escapes to heap" para esta função isolada. Mas ela
// é pequena e o compilador a inlina: num benchmark que só lê p.X, foram
// 0 alocações. Quando o ponteiro é guardado numa variável global, 1
// alocação de 16 bytes por chamada
func criarPonto() *Ponto {
return &Ponto{X: 1, Y: 2}
}
// Retorno por valor não depende de inlining para ficar na stack
func criarPontoValor() Ponto {
return Ponto{X: 1, Y: 2}
}
// Passar um int como interface{} não aloca por si só: aqui o -m diz
// "v does not escape", e o benchmark mediu 0 alocações
func processar(v interface{}) {
_ = v
}
// O boxing aloca quando o valor é guardado ("leaking param: v"):
// 1 alocação de 8 bytes por chamada no benchmark
var guardado interface{}
func guardar(v interface{}) {
guardado = v
}
Slices: pré-alocação
// PROBLEMA: múltiplas realocações
func coletarIDs(usuarios []Usuario) []int {
var ids []int
for _, u := range usuarios {
ids = append(ids, u.ID) // realoca várias vezes
}
return ids
}
// MELHOR: pré-aloca com capacidade conhecida
func coletarIDsOtimizado(usuarios []Usuario) []int {
ids := make([]int, 0, len(usuarios))
for _, u := range usuarios {
ids = append(ids, u.ID) // zero realocações
}
return ids
}
Maps: pré-alocação
// PROBLEMA: map cresce incrementalmente
func indexar(itens []Item) map[string]Item {
idx := make(map[string]Item) // começa vazio
for _, item := range itens {
idx[item.ID] = item
}
return idx
}
// MELHOR: hint de tamanho reduz rehashing
func indexarOtimizado(itens []Item) map[string]Item {
idx := make(map[string]Item, len(itens))
for _, item := range itens {
idx[item.ID] = item
}
return idx
}
sync.Pool: reutilização de objetos
var poolBuffers = sync.Pool{
New: func() any {
return bytes.NewBuffer(make([]byte, 0, 4096))
},
}
func processarRequisicao(dados []byte) string {
buf := poolBuffers.Get().(*bytes.Buffer)
buf.Reset()
defer poolBuffers.Put(buf)
// usa buf sem alocar novo buffer a cada requisição
buf.Write(dados)
buf.WriteString(" processado")
return buf.String()
}
Benchmark comparativo: antes e depois
package cache_test
import (
"fmt"
"testing"
)
// Versão 1: sem pré-alocação
func buscarChavesV1(m map[string]int) []string {
var chaves []string
for k := range m {
chaves = append(chaves, k)
}
return chaves
}
// Versão 2: com pré-alocação
func buscarChavesV2(m map[string]int) []string {
chaves := make([]string, 0, len(m))
for k := range m {
chaves = append(chaves, k)
}
return chaves
}
// Versão 3: reutilizando slice passado como argumento
func buscarChavesV3(m map[string]int, destino []string) []string {
destino = destino[:0]
for k := range m {
destino = append(destino, k)
}
return destino
}
func setupMap(n int) map[string]int {
m := make(map[string]int, n)
for i := 0; i < n; i++ {
m[fmt.Sprintf("chave_%d", i)] = i
}
return m
}
func BenchmarkBuscarChavesV1(b *testing.B) {
m := setupMap(1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
buscarChavesV1(m)
}
}
func BenchmarkBuscarChavesV2(b *testing.B) {
m := setupMap(1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
buscarChavesV2(m)
}
}
func BenchmarkBuscarChavesV3(b *testing.B) {
m := setupMap(1000)
destino := make([]string, 0, 1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
buscarChavesV3(m, destino)
}
}
benchstat: comparando resultados estatisticamente
Medido com Go 1.27, as três versões com 1.000 chaves deram 19,4 µs e 11 alocações (V1), 13,6 µs e 1 alocação (V2) e 10,2 µs sem alocação nenhuma (V3).
go install golang.org/x/perf/cmd/benchstat@latest
# Captura resultados antes da otimização
go test -bench=. -benchmem -count=10 ./... > antes.txt
# Faz a otimização...
# Captura resultados depois
go test -bench=. -benchmem -count=10 ./... > depois.txt
# Compara estatisticamente
benchstat antes.txt depois.txt
Saída real, com a V1 como "antes" e a V2 como "depois", sob o mesmo nome de benchmark:
goos: linux
goarch: amd64
pkg: perf/cache
cpu: AMD Ryzen 7 3700X 8-Core Processor
│ antes.txt │ depois.txt │
│ sec/op │ sec/op vs base │
BuscarChaves-16 19.74µ ± 3% 13.71µ ± 1% -30.58% (p=0.000 n=10)
│ antes.txt │ depois.txt │
│ B/op │ B/op vs base │
BuscarChaves-16 34.36Ki ± 0% 16.00Ki ± 0% -53.43% (p=0.000 n=10)
│ antes.txt │ depois.txt │
│ allocs/op │ allocs/op vs base │
BuscarChaves-16 11.000 ± 0% 1.000 ± 0% -90.91% (p=0.000 n=10)
O ± é a variação entre as dez execuções, e o p é a probabilidade de uma diferença desse tamanho aparecer só por ruído: p=0.000 indica diferença real. Quando o teste não encontra diferença significativa, o benchstat mostra ~ no lugar da porcentagem. Com poucas execuções, ele avisa que precisa de mais amostras; dez é um bom mínimo.
Expondo métricas de runtime
package main
import (
"encoding/json"
"net/http"
"runtime"
"time"
)
type MetricasRuntime struct {
Goroutines int `json:"goroutines"`
HeapAllocMB uint64 `json:"heap_alloc_mb"`
HeapSysMB uint64 `json:"heap_sys_mb"`
HeapObjects uint64 `json:"heap_objects"`
NumGC uint32 `json:"num_gc"`
PausaTotalMs uint64 `json:"pausa_total_ms"`
UltimaGCMs int64 `json:"ultima_gc_ms"`
}
func handlerMetricas(w http.ResponseWriter, r *http.Request) {
var stats runtime.MemStats
runtime.ReadMemStats(&stats)
m := MetricasRuntime{
Goroutines: runtime.NumGoroutine(),
HeapAllocMB: stats.HeapAlloc / 1024 / 1024,
HeapSysMB: stats.HeapSys / 1024 / 1024,
HeapObjects: stats.HeapObjects,
NumGC: stats.NumGC,
PausaTotalMs: stats.PauseTotalNs / 1e6,
UltimaGCMs: time.Unix(0, int64(stats.LastGC)).UnixMilli(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(m)
}
Regras práticas de performance em Go
1. Meça antes de otimizar — use benchmarks e pprof
2. Reduza alocações — é a otimização mais impactante em Go
3. Pré-aloque slices e maps quando o tamanho é conhecido
4. Use sync.Pool para objetos frequentemente alocados e descartados
5. Prefira valores a ponteiros para structs pequenas
6. Use strings.Builder para concatenação de strings em loops
7. Em hot paths, cuidado com interface{}/any que é guardada — aí o boxing aloca
8. Leia o escape analysis — go build -gcflags="-m"
9. Use benchstat para comparações estatisticamente válidas
10. O GC é seu amigo — reduzir alocações beneficia automaticamente
Otimizar em Go começa por medir: um benchmark isola a função, o pprof mostra onde o tempo e a memória vão, o trace mostra o que acontece ao longo do tempo e o benchstat diz se a diferença entre duas versões é real ou ruído. As ferramentas também precisam ser lidas com cuidado. Um perfil de mutex vazio pode só estar desligado, e o -m do compilador fala de cada função isolada, enquanto o inlining muda o que de fato aloca. Uma saída "típica" copiada de outro lugar não substitui a sua: os números deste artigo foram medidos numa máquina específica, e o que vale para o seu código é o que o seu benchmark mostrar.
Fontes e leituras recomendadas
-
Documentação pprof — Referência completa do pacote net/http/pprof. https://pkg.go.dev/net/http/pprof
-
Go Blog: Profiling Go Programs — Tutorial oficial de profiling. https://go.dev/blog/pprof
-
Diagnostics — Visão geral de todas as ferramentas de diagnóstico. https://go.dev/doc/diagnostics
-
benchstat — Ferramenta de comparação estatística de benchmarks. https://pkg.go.dev/golang.org/x/perf/cmd/benchstat
-
Go execution tracer — Documentação do runtime/trace. https://pkg.go.dev/runtime/trace
-
High Performance Go Workshop — Dave Cheney — Workshop completo sobre performance. https://dave.cheney.net/high-performance-go-workshop/gopherchina-2019.html
Exercícios
Exercício 1
Um serviço com net/http/pprof tem latência alta sob carga, e a suspeita é disputa por um mutex. O time coleta /debug/pprof/mutex durante o pico, e o perfil vem vazio. A conclusão é que não há disputa. Está certa?
Ver resposta
✓ Resposta: Não necessariamente. Os perfis de mutex e de bloqueio custam algo a cada operação, e por isso vêm desligados: o runtime só registra amostras depois de runtime.SetMutexProfileFraction e runtime.SetBlockProfileRate receberem um valor maior que zero. O endpoint responde normalmente mesmo desligado, só que sem dados. Num teste com oito goroutines disputando um mutex por milhares de iterações, os dois perfis vieram com zero amostras; depois de SetMutexProfileFraction(5) e SetBlockProfileRate(1), a mesma carga produziu amostras nos dois. A fração 5 registra, em média, um evento de disputa a cada cinco, e o rate 1 registra todo bloqueio, o que pesa em produção; valores maiores, como 100 ou 10000 nanossegundos para o block, reduzem o custo. A regra geral é desconfiar de um perfil vazio até confirmar que ele estava ligado.
Exercício 2
O go build -gcflags="-m" informa &Ponto{...} escapes to heap na função criarPonto. Um desenvolvedor reescreve todas as chamadas para usar criarPontoValor, esperando eliminar alocações, mas o benchmark do chamador não muda: já eram 0 alocações. Como?
Ver resposta
✓ Resposta: A análise de escape do -m é feita por função: olhando só criarPonto, o ponteiro sai da função, e o objeto precisa sobreviver a ela. Mas o compilador também informa can inline criarPonto, e, quando a função é inlinada, o corpo dela passa a fazer parte do chamador, e a análise é refeita ali. Se o chamador só lê p.X e não guarda o ponteiro, o objeto não escapa e fica na pilha. Num teste, o benchmark que chamava criarPonto e lia um campo marcou 0 alocações; o que guardava o resultado numa variável global marcou 1 alocação de 16 bytes. O -m é um mapa, e não um veredito: quem decide se vale mudar o código é um benchmark com -benchmem no ponto de uso. Retorno por valor continua sendo uma boa escolha para structs pequenas, porque não depende de inlining.
Exercício 3
Uma revisão de código pede para trocar todo parâmetro interface{} por tipos concretos, "porque interface sempre aloca". Num benchmark, processar(i), que recebe interface{} e faz _ = v, marca 0 alocações. Quando a passagem para interface aloca de fato?
Ver resposta
✓ Resposta: Converter um valor para interface exige guardar o valor em algum lugar para o qual a interface aponte. Se a interface não sobrevive à chamada, esse lugar pode ser a pilha, e não há alocação; o -m informa v does not escape. Se a função guarda a interface, numa variável global, num campo, num canal ou num slice que sobrevive, o valor precisa ir para o heap, e o -m informa leaking param: v. Num teste, processar marcou 0 alocações, e guardar, que atribui a uma global, marcou 1 alocação de 8 bytes por chamada. Inteiros pequenos, de 0 a 255, são uma exceção: o runtime os aponta para uma tabela fixa, sem alocar, e por isso os benchmarks usam valores maiores. A regra útil não é "interface aloca", e sim "interface guardada aloca", e o lugar de verificar é o hot path, com -benchmem.
Exercício 4
Um script de CI roda go test -bench=. -cpuprofile=cpu.prof ./... para coletar perfis de todo o projeto, e falha sem rodar nenhum benchmark. Qual é a mensagem, e como coletar os perfis?
Ver resposta
✓ Resposta: Cada pacote de teste é um binário separado, e um arquivo de perfil só pode vir de um processo. Com mais de um pacote, o go test recusa a flag antes de começar: num teste, a mensagem foi cannot use -cpuprofile flag with multiple packages, e o mesmo aconteceu com -memprofile e com -trace. O caminho é rodar um pacote por vez, cada um com o seu arquivo, por exemplo num laço de shell sobre a saída de go list ./..., gerando cpu-pacote.prof para cada um. O go tool pprof aceita vários arquivos de perfil de uma vez e os soma, o que permite uma visão geral depois. Na prática, perfis por pacote são mais úteis que um perfil do projeto inteiro, porque o benchmark de cada pacote mede um código específico.
Exercício 5
No benchmark do artigo, buscarChavesV1 faz 11 alocações para 1.000 chaves, e a buscarChavesV2, com make([]string, 0, len(m)), faz 1. De onde vêm as 11, e por que a V3 chega a 0?
Ver resposta
✓ Resposta: Um slice começa sem capacidade, e cada append que encontra o array cheio aloca um novo, maior, e copia tudo para ele. A capacidade cresce em proporção, dobrando enquanto o slice é pequeno e crescendo menos depois, e por isso o número de alocações cresce com o logaritmo do tamanho: para 1.000 elementos, foram 11 realocações, somando 34 KiB alocados, quando o resultado final tem 16 KiB. A V2 aloca uma vez, já com a capacidade certa: no benchstat, ficou 30,6% mais rápida, com 53,4% menos bytes. A V3 recebe um slice do chamador, zera o comprimento com destino[:0] e reaproveita o array, e por isso não aloca nada: 10,2 µs, contra 19,4 µs da V1. A V3 tem um custo de interface, porém: o chamador precisa saber que o resultado aponta para o array que ele passou, e que a próxima chamada vai sobrescrevê-lo.