Profiling e otimização de performance em Go

Profiling e otimização de performance em Go

Profiling em Go medido: benchmarks com -benchmem, pprof por HTTP e em testes, perfis de mutex e block que vêm desligados, flamegraph, trace por pacote, escape analysis com inlining, interface que só aloca quando é guardada e benchstat com saída real.
Golang

• • 17 min de leitura

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

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.

Comentários

Mais em Golang

Métodos em Structs: receivers por valor e por ponteiro
Métodos em Structs: receivers por valor e por ponteiro

Métodos em Go: receiver por valor e por ponteiro, a conversão automática para…

Goroutines: concorrência leve e o modelo de execução do Go
Goroutines: concorrência leve e o modelo de execução do Go

Goroutines em Go: o custo comparado ao de threads, o scheduler M:N e a…

Compilação cruzada, binários estáticos e Docker com Go
Compilação cruzada, binários estáticos e Docker com Go

Compilação cruzada e Docker com Go: -ldflags e o que o debug.ReadBuildInfo já…