Bem-vindo à aula sobre profiling com pprof! Quando seus programas Go ficam lentos ou consomem muita memória, você precisa de ferramentas para descobrir onde estão os gargalos. O pacote runtime/pprof e o subpacote net/http/pprof fornecem funcionalidades poderosas para coletar e analisar dados de desempenho. Nesta aula, você aprenderá a gerar perfis de CPU e memória, integrar pprof a um servidor HTTP, interpretar os resultados e usar essas informações para otimizar seu código de forma direcionada.

Vamos explorar desde a instrumentação básica até a análise visual com ferramentas como go tool pprof e go tool trace. Ao final, você terá um fluxo de trabalho completo para identificar e corrigir problemas de performance em aplicações Go.

CPU e memória

O profiling de CPU e memória é essencial para entender o comportamento do seu programa. O pprof coleta amostras em intervalos regulares durante a execução, criando um retrato do que o programa está fazendo. Para CPU, ele captura a pilha de chamadas em cada amostra, permitindo identificar funções que consomem mais tempo de processamento. Para memória, ele rastreia alocações e retenções, mostrando onde a memória está sendo usada.

Em Go, você pode gerar perfis programaticamente usando runtime/pprof. O exemplo a seguir mostra como iniciar e parar um perfil de CPU, salvando os dados em um arquivo.

package main

import (
	"os"
	"runtime/pprof"
)

func main() {
	f, err := os.Create("cpu.prof")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	if err := pprof.StartCPUProfile(f); err != nil {
		panic(err)
	}
	defer pprof.StopCPUProfile()

	// Seu código aqui
	slowFunction()
}

Para memória, você pode obter uma amostra do heap em um ponto específico:

f, _ := os.Create("mem.prof")
pprof.WriteHeapProfile(f)
f.Close()

Esses arquivos podem ser analisados com go tool pprof ou visualmente com go tool pprof -http.

net/http/pprof

Em aplicações web, você pode expor endpoints de profiling diretamente no seu servidor HTTP usando net/http/pprof. Basta importar o pacote (com um underscore para registrar os handlers) e os endpoints como /debug/pprof/ estarão disponíveis.

package main

import (
	"net/http"
	_ "net/http/pprof"
)

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

	// Seu servidor principal
	http.HandleFunc("/", handler)
	http.ListenAndServe(":8080", nil)
}

Com isso, você pode acessar http://localhost:6060/debug/pprof/ para ver a lista de perfis disponíveis, como profile (CPU), heap (memória), goroutine, block, etc. Por exemplo, para baixar um perfil de CPU de 30 segundos, use curl http://localhost:6060/debug/pprof/profile?seconds=30.

É importante proteger esses endpoints em produção, pois eles podem expor informações sensíveis. Use autenticação ou restrinja o acesso a redes internas.

Interpretando

Depois de gerar um perfil, você precisa interpretá-lo. O comando go tool pprof oferece uma interface interativa para explorar os dados. Por exemplo, para analisar um perfil de CPU:

go tool pprof cpu.prof

Dentro do prompt, você pode usar comandos como top para listar as funções mais quentes, list para ver o código-fonte com anotações, e web para gerar um gráfico visual (requer Graphviz).

Vamos supor que o perfil mostre que a função slowFunction consome 80% do tempo. Você pode investigar com list slowFunction para ver exatamente onde o tempo é gasto.

(pprof) list slowFunction

Isso mostrará o código-fonte com contagens de amostras por linha, ajudando a identificar gargalos específicos.

Para memória, o comando go tool pprof -sample_index=alloc_objects mem.prof pode mostrar onde as alocações estão acontecendo. Você também pode usar go tool pprof -http=:8081 mem.prof para abrir uma interface gráfica no navegador, com gráficos e visualizações interativas.

Além disso, o pacote runtime/pprof permite criar perfis de goroutines, bloqueios e mutexes, que são úteis para diagnosticar problemas de concorrência.

Otimização guiada

Com os dados do pprof, você pode tomar decisões de otimização baseadas em evidências, em vez de suposições. O processo típico é:

  1. Medir: gere perfis de CPU e memória.
  2. Identificar: use go tool pprof para encontrar as funções mais caras.
  3. Otimizar: aplique técnicas como redução de alocações, uso de concorrência, ou algoritmos mais eficientes.
  4. Verificar: repita o profiling para confirmar a melhoria.

Por exemplo, se o perfil mostra que uma função está fazendo muitas alocações, você pode otimizar reutilizando buffers ou usando tipos de valor em vez de ponteiros. Se há contenção de locks, você pode usar estruturas lock-free ou particionar dados.

Vamos ver um exemplo prático. Suponha que temos uma função que processa uma lista de strings e concatena repetidamente:

func joinStrings(parts []string) string {
	result := ""
	for _, p := range parts {
		result += p
	}
	return result
}

Essa função é ineficiente porque cada concatenação cria uma nova string, resultando em muitas alocações. O profiling de memória mostraria isso. Uma otimização seria usar strings.Builder:

func joinStrings(parts []string) string {
	var b strings.Builder
	for _, p := range parts {
		b.WriteString(p)
	}
	return b.String()
}

Essa mudança reduz drasticamente as alocações e melhora o desempenho.

Lembre-se: otimize apenas o que é necessário. Use o profiling para encontrar os gargalos reais, não otimize código que não está causando problemas.

Boas práticas e observações finais

Aqui estão algumas recomendações ao trabalhar com pprof:

  • Sempre faça profiling em um ambiente representativo de produção, com cargas de trabalho realistas.
  • Execute perfis de CPU por um período significativo (pelo menos 30 segundos) para obter amostras estatisticamente relevantes.
  • Compare perfis antes e depois de mudanças para quantificar melhorias.
  • Use go test -bench para benchmarks unitários e -cpuprofile / -memprofile para gerar perfis de testes.
  • Em produção, considere usar ferramentas como net/http/pprof com segurança, ou colete perfis em intervalos específicos.

O profiling é uma habilidade essencial para qualquer programador Go que deseja escrever software eficiente. Com o pprof, você tem uma ferramenta poderosa para entender o comportamento do seu código e otimizá-lo de forma guiada por dados.

Referências

Exercícios

  1. Exercício 1: Escreva um programa Go que gere um perfil de CPU de 5 segundos e salve em cpu.prof. Execute uma função simples (por exemplo, um loop de 1 milhão de iterações) durante esse tempo.
  2. ✓ Resposta:
    package main
    
    import (
    	"os"
    	"runtime/pprof"
    	"time"
    )
    
    func main() {
    	f, _ := os.Create("cpu.prof")
    	defer f.Close()
    	pprof.StartCPUProfile(f)
    	defer pprof.StopCPUProfile()
    
    	// Função simples
    	for i := 0; i < 1000000; i++ {
    		_ = i * i
    	}
    	time.Sleep(5 * time.Second)
    }
    
  3. Exercício 2: Crie um servidor HTTP simples que exponha os endpoints de pprof na porta 6060 e um endpoint principal na porta 8080. Teste com curl para baixar um perfil de heap.
  4. ✓ Resposta:
    package main
    
    import (
    	"fmt"
    	"net/http"
    	_ "net/http/pprof"
    )
    
    func main() {
    	// Endpoints de pprof em 6060
    	go func() {
    		http.ListenAndServe("localhost:6060", nil)
    	}()
    
    	// Servidor principal
    	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    		fmt.Fprintf(w, "Hello, World!")
    	})
    	http.ListenAndServe(":8080", nil)
    }
    
  5. Exercício 3: Dado o seguinte código ineficiente, use pprof para identificar o gargalo de memória e otimize-o:
  6. func concat(parts []string) string {
    	result := ""
    	for _, p := range parts {
    		result += p
    	}
    	return result
    }
    

    ✓ Resposta: A otimização é usar strings.Builder para evitar múltiplas alocações:
    import "strings"
    
    func concat(parts []string) string {
    	var b strings.Builder
    	for _, p := range parts {
    		b.WriteString(p)
    	}
    	return b.String()
    }
    
  7. Exercício 4: Explique como você interpretaria um perfil de CPU que mostra que a função parse consome 70% do tempo. Que comandos você usaria para investigar?
  8. ✓ Resposta: Usaria go tool pprof cpu.prof e depois list parse para ver as linhas exatas onde o tempo é gasto. Também poderia usar top para ver o ranking geral e web para gerar um gráfico visual.
  9. Exercício 5: Escreva um benchmark usando testing que compare as duas versões da função concat (original e otimizada) e execute com -benchmem para ver as alocações.
  10. ✓ Resposta:
    package concat
    
    import (
    	"strings"
    	"testing"
    )
    
    func concat(parts []string) string {
    	result := ""
    	for _, p := range parts {
    		result += p
    	}
    	return result
    }
    
    func concatBuilder(parts []string) string {
    	var b strings.Builder
    	for _, p := range parts {
    		b.WriteString(p)
    	}
    	return b.String()
    }
    
    func BenchmarkConcat(b *testing.B) {
    	parts := []string{"a", "b", "c", "d", "e"}
    	for i := 0; i < b.N; i++ {
    		concat(parts)
    	}
    }
    
    func BenchmarkConcatBuilder(b *testing.B) {
    	parts := []string{"a", "b", "c", "d", "e"}
    	for i := 0; i < b.N; i++ {
    		concatBuilder(parts)
    	}
    }
    

    Execute com go test -bench=. -benchmem.