Pular para o conteúdo principal

victorstein.dev

92/100 Dias de Golang - Profiling com pprof

Table of Contents

# Profiling com pprof

No dia 27 vimos testes de benchmark, que respondem à pergunta “quão rápido é esse código?”. Mas quando o benchmark mostra que algo está lento, a próxima pergunta é: “lento onde?”. Dá para tentar adivinhar, sair colocando time.Now() pelo código… ou usar um profiler.

Um profiler observa o programa enquanto ele roda e mostra onde o tempo e a memória estão sendo gastos. O Go já vem com um, o pprof, sem precisar instalar nada.

# O código lento

Vamos usar uma função que recebe linhas de log e extrai os IPs, devolvendo tudo separado por vírgula:

package logs

import (
	"regexp"
	"strings"
)

// ExtrairIPs recebe linhas de log e retorna os IPs encontrados, separados por vírgula
func ExtrairIPs(linhas []string) string {
	resultado := ""
	for _, linha := range linhas {
		re := regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`)
		ip := re.FindString(linha)
		if ip != "" {
			if resultado != "" {
				resultado += ","
			}
			resultado += ip
		}
	}
	return resultado
}

Talvez você já tenha visto os problemas dessa função, mas vamos fingir que não e deixar o profiler mostrar para a gente.

O benchmark, com 5.000 linhas de log no formato 2025-12-09 10:00:00 GET /api/usuarios 200 192.168.0.1:

package logs

import (
	"fmt"
	"testing"
)

func gerarLinhas(n int) []string {
	linhas := make([]string, n)
	for i := range n {
		linhas[i] = fmt.Sprintf("2025-12-09 10:00:00 GET /api/usuarios 200 192.168.%d.%d", i%256, (i/256)%256)
	}
	return linhas
}

var linhas = gerarLinhas(5000)

func BenchmarkExtrairIPs(b *testing.B) {
	for b.Loop() {
		ExtrairIPs(linhas)
	}
}

O b.Loop() é a forma nova de escrever benchmarks, disponível desde o Go 1.24. Ele substitui o for i := 0; i < b.N; i++ que usamos no dia 27.

# Gerando o profile

O go test já sabe gerar os profiles, basta passar as flags -cpuprofile e -memprofile:

go test -bench=. -benchmem -cpuprofile cpu.out -memprofile mem.out
BenchmarkExtrairIPs-24     25    47748258 ns/op    412879259 B/op    320882 allocs/op

Cada execução leva 47ms, aloca 412MB e faz 320 mil alocações, para processar só 5.000 linhas. Tem alguma coisa muito errada.

Além do resultado, foram criados os arquivos cpu.out e mem.out. Vamos analisá-los com o go tool pprof.

# Analisando a CPU

O comando -top mostra as funções que mais gastaram tempo. Usando -cum ordenamos pelo tempo acumulado, ou seja, o tempo da função somado ao de todas as funções que ela chama:

go tool pprof -top -cum cpu.out
      flat  flat%   sum%        cum   cum%
         0     0%     0%     1280ms 55.17%  logs/logs.BenchmarkExtrairIPs
         0     0%     0%     1280ms 55.17%  logs/logs.ExtrairIPs
         0     0%     0%     1280ms 55.17%  testing.(*B).run1.func1
         0     0%     0%     1280ms 55.17%  testing.(*B).runN
         0     0%     0%      880ms 37.93%  runtime.systemstack
         0     0%     0%      670ms 28.88%  runtime.gcBgMarkWorker
         0     0%     0%      590ms 25.43%  regexp.Compile (inline)
         0     0%     0%      590ms 25.43%  regexp.MustCompile
         0     0%     0%      590ms 25.43%  regexp.compile

As colunas:

  • flat: tempo gasto dentro da própria função;
  • cum: tempo da função mais as funções que ela chamou.

Duas coisas chamam atenção:

  1. regexp.MustCompile consome 25% do tempo total. Compilar uma expressão regular é caro, e estamos fazendo isso 5.000 vezes por execução, uma para cada linha.
  2. runtime.gcBgMarkWorker consome quase 29%. Esse é o garbage collector. Quase um terço do tempo o programa está limpando memória, ou seja, estamos criando lixo demais.

Para ver exatamente quais linhas são o problema, usamos o -list com o nome da função:

go tool pprof -list 'ExtrairIPs$' cpu.out
         0      1.28s (flat, cum) 55.17% of Total
         .          .      9:func ExtrairIPs(linhas []string) string {
         .          .     10:	resultado := ""
         .          .     11:	for _, linha := range linhas {
         .      590ms     12:		re := regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`)
         .      170ms     13:		ip := re.FindString(linha)
         .          .     14:		if ip != "" {
         .          .     15:			if resultado != "" {
         .      240ms     16:				resultado += ","
         .          .     17:			}
         .      280ms     18:			resultado += ip
         .          .     19:		}
         .          .     20:	}
         .          .     21:	return resultado
         .          .     22:}

Agora fica bem claro: a linha 12 (compilar a regex) e as linhas 16 e 18 (concatenar strings) são os gargalos. A busca em si, que é o que a função realmente deveria fazer, gasta só 170ms.

# Analisando a memória

O profile de memória responde a outra pergunta: quem está alocando? Com -sample_index=alloc_space vemos o total alocado durante o benchmark:

go tool pprof -sample_index=alloc_space -list 'ExtrairIPs$' mem.out
    7.30GB     8.08GB (flat, cum) 99.88% of Total
         .   719.28MB     12:		re := regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`)
         .    76.53MB     13:		ip := re.FindString(linha)
    3.66GB     3.66GB     16:				resultado += ","
    3.64GB     3.64GB     18:			resultado += ip

7,3GB alocados só nas duas linhas de concatenação! Isso acontece porque strings em Go são imutáveis (falamos de strings no dia 37). Cada += cria uma string nova, copia todo o conteúdo antigo para ela e adiciona o pedaço novo. Conforme o resultado cresce, cada cópia fica maior. É por isso que o GC aparecia tanto no profile de CPU: ele estava limpando todas essas strings intermediárias.

O -sample_index aceita quatro valores:

  • alloc_space / alloc_objects: tudo que foi alocado (em bytes / em quantidade) durante a execução;
  • inuse_space / inuse_objects: o que ainda estava em uso no momento do profile. É o que usamos para caçar memory leaks.

# Corrigindo

Com os gargalos encontrados, a correção é direta:

  1. Compilar a regex uma única vez, numa variável do pacote;
  2. Trocar a concatenação por um strings.Builder, que vai escrevendo num buffer que cresce conforme necessário, sem recopiar tudo a cada passo.
var ipRegex = regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`)

// ExtrairIPsOtimizado faz o mesmo que ExtrairIPs, sem os gargalos
func ExtrairIPsOtimizado(linhas []string) string {
	var sb strings.Builder
	for _, linha := range linhas {
		ip := ipRegex.FindString(linha)
		if ip != "" {
			if sb.Len() > 0 {
				sb.WriteByte(',')
			}
			sb.WriteString(ip)
		}
	}
	return sb.String()
}

Rodando os dois benchmarks lado a lado:

BenchmarkExtrairIPs-24             	      25	  47748258 ns/op	412879259 B/op	  320882 allocs/op
BenchmarkExtrairIPsOtimizado-24    	     296	   4023847 ns/op	    285895 B/op	      21 allocs/op
AntesDepois
Tempo por execução47,7ms4ms
Memória por execução412MB285KB
Alocações320.88221

12x mais rápido e 1.400x menos memória. E não precisamos adivinhar nada: o profiler apontou as linhas exatas.

Antes de otimizar, vale escrever um teste garantindo que as duas versões retornam o mesmo resultado. Otimização que muda o comportamento é bug.

# Interface web e flame graph

Ler a saída no terminal funciona, mas o pprof também tem uma interface web bem mais agradável:

go tool pprof -http=:8080 cpu.out

Ela abre no navegador com várias visualizações. A mais útil é o flame graph (menu View → Flame Graph): cada barra é uma função, a largura é o tempo gasto e as barras de baixo são chamadas pelas de cima. Barras largas são os gargalos, dá para encontrá-los de relance.

A visualização em grafo (View → Graph) precisa do Graphviz instalado (sudo apt install graphviz); o flame graph funciona sem ele.

# Profiling de uma aplicação rodando

Benchmarks são ótimos para analisar uma função isolada, mas às vezes o problema só aparece com a aplicação de verdade, recebendo tráfego real. Para isso existe o pacote net/http/pprof. Basta importá-lo:

package main

import (
	"fmt"
	"log"
	"net/http"
	_ "net/http/pprof"

	"logs/logs"
)

var linhas []string

func init() {
	for i := range 5000 {
		linhas = append(linhas, fmt.Sprintf("2025-12-09 10:00:00 GET /api/usuarios 200 192.168.%d.%d", i%256, (i/256)%256))
	}
}

func ipsHandler(w http.ResponseWriter, r *http.Request) {
	ips := logs.ExtrairIPs(linhas)
	fmt.Fprintf(w, "%d bytes de IPs\n", len(ips))
}

func main() {
	http.HandleFunc("/ips", ipsHandler)
	log.Println("servidor rodando em :8092")
	log.Fatal(http.ListenAndServe(":8092", nil))
}

O _ no import significa que importamos o pacote só pelos efeitos colaterais: na inicialização ele registra as rotas /debug/pprof/ no http.DefaultServeMux. Acessando http://localhost:8092/debug/pprof/ temos a lista de profiles disponíveis: allocs, block, goroutine, heap, mutex, profile, trace…

Com o servidor recebendo requisições, coletamos 5 segundos de CPU direto da aplicação:

go tool pprof -top -cum 'http://localhost:8092/debug/pprof/profile?seconds=5'
      flat  flat%   sum%        cum   cum%
     0.01s 0.093% 0.093%      5.25s 48.66%  runtime.systemstack
         0     0% 0.093%      4.92s 45.60%  logs/logs.ExtrairIPs
         0     0% 0.093%      4.92s 45.60%  main.ipsHandler
         0     0% 0.093%      4.92s 45.60%  net/http.(*ServeMux).ServeHTTP
         ...
         0     0% 0.093%      4.07s 37.72%  runtime.gcBgMarkWorker

O mesmo diagnóstico, agora com a aplicação rodando: ExtrairIPs e o garbage collector dominando. O pprof também salva uma cópia do profile em ~/pprof/, para analisar depois.

Outros profiles úteis:

# memória em uso agora (memory leaks)
go tool pprof http://localhost:8092/debug/pprof/heap

# todas as goroutines e onde estão paradas (goroutine leaks, deadlocks)
curl 'http://localhost:8092/debug/pprof/goroutine?debug=1'

O profile de goroutines é muito útil para encontrar goroutines que nunca terminam, como aquela do exemplo de WithTimeout do dia 89 se o channel não tivesse buffer.

## Cuidado em produção

As rotas do pprof mostram detalhes internos da aplicação e permitem que qualquer um dispare um profile, que consome recursos. Nunca exponha o /debug/pprof publicamente. O mais comum é rodar o pprof num servidor separado, escutando só em localhost, enquanto a aplicação usa seu próprio ServeMux:

go func() {
	log.Println(http.ListenAndServe("localhost:6060", nil))
}()

mux := http.NewServeMux()
mux.HandleFunc("/ips", ipsHandler)
log.Fatal(http.ListenAndServe(":8092", mux))

Como as rotas do pprof foram registradas no DefaultServeMux, elas só existem no servidor da porta 6060, que não é acessível de fora da máquina.

# Execution tracer

O pprof funciona por amostragem: algumas vezes por segundo ele “fotografa” o que o programa está fazendo. Isso é ótimo para encontrar onde o tempo é gasto, mas não mostra quando as coisas acontecem. Para problemas de concorrência, como goroutines esperando demais por um lock, um channel travando tudo ou o GC pausando no pior momento, existe o execution tracer, que registra cada evento do runtime.

Pode ser gerado pelo go test ou pela aplicação rodando:

go test -bench=. -trace=trace.out
# ou
curl -o trace.out 'http://localhost:8092/debug/pprof/trace?seconds=2'

E visualizado com:

go tool trace trace.out

Ele abre no navegador uma linha do tempo mostrando, para cada processador, qual goroutine estava rodando a cada instante, quando o GC entrou em ação e quanto tempo cada goroutine ficou bloqueada. É uma ferramenta mais avançada, que conversa bastante com o que vimos no dia 34 sobre como o runtime gerencia as goroutines.

# Referências

  1. Profiling Go Programs
  2. Documentação runtime/pprof
  3. Documentação net/http/pprof
  4. Diagnostics - Documentação do Go
  5. More powerful Go execution traces