Introdução ao Profiling de Memória em Go
Profiling é a análise sistemática de como seu programa utiliza recursos, especialmente memória. Em Go, o pacote runtime/pprof fornece ferramentas poderosas para medir alocações, rastrear vazamentos de memória e identificar gargalos de performance. Diferentemente de linguagens como C ou C++, Go oferece profiling integrado sem necessidade de bibliotecas externas complexas, o que torna a otimização de memória acessível desde o desenvolvimento inicial.
Quando você executa um programa Go, cada alocação de memória passa pelo garbage collector (GC). Entender quanto e quando seu código aloca memória é fundamental para evitar pausas longas do GC, reduzir consumo de recursos e melhorar a responsividade da aplicação. Este artigo o guiará desde conceitos básicos até técnicas avançadas de otimização.
Fundamentos do pprof e Coleta de Dados
O que é pprof e como funciona
O pprof é um profiler estatístico que amostra a execução do programa em intervalos regulares. Para memória, ele registra quais funções allocam quantos bytes, permitindo visualizar onde seu programa gasta recursos. O profiler funciona coletando amostras a cada 512 KB de alocação por padrão — isso significa que nem toda alocação é registrada, apenas uma fração representativa.
Go fornece vários perfis: heap (alocações de memória), goroutine (número de goroutines ativas), threadcreate (criação de threads) e allocs (todas as alocações). O perfil de heap é o mais importante para otimizações.
Habilitando pprof com net/http/pprof
A forma mais prática de iniciar profiling em uma aplicação Go é através do servidor HTTP integrado. Quando você importa _ "net/http/pprof", Go registra automaticamente endpoints que expõem os perfis:
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof"
"time"
)
func processData() {
// Simula processamento que aloca muita memória
for i := 0; i < 1000; i++ {
data := make([]byte, 1024*1024) // Aloca 1 MB
_ = data
time.Sleep(10 * time.Millisecond)
}
}
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
fmt.Println("Profiler disponível em http://localhost:6060/debug/pprof/")
processData()
}
Ao rodar este programa, você acessa http://localhost:6060/debug/pprof/ para visualizar todos os perfis disponíveis. O endpoint /debug/pprof/heap fornece o heap profile atual.
Coleta programática de perfis
Para aplicações sem servidor HTTP (como CLIs ou workers), você pode coletar perfis diretamente usando os.Create e pprof.WriteHeapProfile:
package main
import (
"fmt"
"os"
"runtime/pprof"
)
func processData() {
// Simula picos de alocação
for i := 0; i < 500; i++ {
largeSlice := make([]string, 10000)
for j := range largeSlice {
largeSlice[j] = fmt.Sprintf("item_%d", j)
}
}
}
func main() {
// Criar arquivo para armazenar o heap profile
f, err := os.Create("heap.prof")
if err != nil {
panic(err)
}
defer f.Close()
// Forçar garbage collection para limpeza
defer pprof.WriteHeapProfile(f)
processData()
fmt.Println("Heap profile salvo em heap.prof")
}
Após executar este programa, você terá um arquivo heap.prof que pode ser analisado com ferramentas como go tool pprof.
Analisando Heap Dumps e Identificando Vazamentos
Interpretando o heap profile
Um heap dump captura o estado da memória em um momento específico. Para análise visual e interativa, use:
go tool pprof http://localhost:6060/debug/pprof/heap
Ou com arquivo salvo:
go tool pprof heap.prof
Dentro do pprof, você encontra comandos úteis:
- top: lista as funções que mais allocam memória
- list <função>: mostra o código-fonte com alocações por linha
- web: gera gráfico visual (requer Graphviz)
- alloc_space: total alocado desde o início (inclui coletado)
- alloc_objects: número de objetos alocados
Exemplo prático: detectando vazamento de memória
Considere um servidor que processa requisições e acumula dados sem limpeza:
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof"
"sync"
"time"
)
var cache = make(map[string][]byte)
var cacheMutex sync.Mutex
// BUG: função que aloca sem limite
func handleRequest(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
cacheMutex.Lock()
// Cada requisição adiciona 10 MB ao cache sem remover antigos
cache[key] = make([]byte, 10*1024*1024)
cacheMutex.Unlock()
fmt.Fprintf(w, "Cached %s\n", key)
}
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
http.HandleFunc("/request", handleRequest)
go func() {
// Simula requisições contínuas
for i := 0; i < 100; i++ {
http.Get(fmt.Sprintf("http://localhost:6060/request?key=item_%d", i))
time.Sleep(100 * time.Millisecond)
}
}()
log.Println(http.ListenAndServe("localhost:8080", nil))
}
Ao acessar /debug/pprof/heap, você verá que handleRequest cresce indefinidamente. A solução é implementar limpeza:
func handleRequest(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")
cacheMutex.Lock()
// Limita tamanho máximo do cache
if len(cache) > 1000 {
// Remove item mais antigo (simplificado)
for k := range cache {
delete(cache, k)
break
}
}
cache[key] = make([]byte, 10*1024*1024)
cacheMutex.Unlock()
fmt.Fprintf(w, "Cached %s\n", key)
}
Gerando perfis com CPU para contexto
Às vezes, entender uso de memória requer contexto de CPU. Você pode combinar perfis:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"os"
"runtime"
"runtime/pprof"
"sync"
)
func expensiveComputation() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// Aloca 50 MB por goroutine
data := make([][]byte, 1000)
for j := range data {
data[j] = make([]byte, 50*1024)
// Simula processamento
for k := range data[j] {
data[j][k] = byte(k % 256)
}
}
}()
}
wg.Wait()
}
func main() {
cpuProfile, _ := os.Create("cpu.prof")
defer cpuProfile.Close()
pprof.StartCPUProfile(cpuProfile)
defer pprof.StopCPUProfile()
memProfile, _ := os.Create("mem.prof")
defer memProfile.Close()
defer pprof.WriteHeapProfile(memProfile)
expensiveComputation()
// Força GC antes de escrever heap
runtime.GC()
log.Println("Perfis salvos: cpu.prof e mem.prof")
}
Otimizações Práticas de Memória
Object pooling e sync.Pool
Quando você aloca muitos objetos temporários do mesmo tipo, cada alocação consome memória e gera trabalho para o GC. Object pooling reutiliza estruturas através de sync.Pool:
package main
import (
"bytes"
"fmt"
"sync"
"time"
)
type RequestBuffer struct {
Data *bytes.Buffer
Count int
}
// Pool reutiliza buffers em vez de alocar novos
var bufferPool = sync.Pool{
New: func() interface{} {
return &RequestBuffer{
Data: &bytes.Buffer{},
}
},
}
func processRequest(id int) {
buf := bufferPool.Get().(*RequestBuffer)
defer bufferPool.Put(buf)
// Reusa buffer existente
buf.Data.Reset()
buf.Count = 0
// Simula processamento
for i := 0; i < 100; i++ {
fmt.Fprintf(buf.Data, "Line %d\n", i)
buf.Count++
}
// Resultado fica no buffer reutilizável
}
func main() {
start := time.Now()
for i := 0; i < 10000; i++ {
processRequest(i)
}
fmt.Printf("Tempo: %v\n", time.Since(start))
}
Com sync.Pool, objetos descartados retornam ao pool em vez de aguardar coleta de lixo. Go automaticamente limpa o pool entre ciclos de GC, então é seguro para este propósito.
Pré-alocação e evitar realocações
Slices em Go crescem dinamicamente, mas cada realocação copia dados. Quando você sabe o tamanho aproximado, pré-alocar economiza memória e CPU:
package main
import (
"fmt"
"time"
)
// Ineficiente: cresce dinamicamente
func buildSliceNaive(size int) []int {
var result []int
for i := 0; i < size; i++ {
result = append(result, i)
}
return result
}
// Eficiente: pré-aloca com capacidade
func buildSliceOptimized(size int) []int {
result := make([]int, 0, size) // capacity = size
for i := 0; i < size; i++ {
result = append(result, i)
}
return result
}
func benchmark(name string, fn func(int) []int, size int) {
start := time.Now()
for i := 0; i < 10000; i++ {
fn(size)
}
fmt.Printf("%s: %v\n", name, time.Since(start))
}
func main() {
benchmark("Naive", buildSliceNaive, 1000)
benchmark("Optimized", buildSliceOptimized, 1000)
}
A versão otimizada aloca uma única vez com capacidade exata, evitando realocações quando append é chamado.
Reduzindo alocações em loops críticos
Loops que executam milhões de vezes precisam alocar minimamente. Use variáveis locais reutilizáveis:
package main
import (
"bytes"
"fmt"
"time"
)
// Aloca buffer a cada iteração (RUIM)
func processLoopNaive(count int) int {
total := 0
for i := 0; i < count; i++ {
buf := &bytes.Buffer{}
fmt.Fprintf(buf, "Item %d", i)
total += buf.Len()
}
return total
}
// Reutiliza buffer (BOM)
func processLoopOptimized(count int) int {
total := 0
buf := &bytes.Buffer{}
for i := 0; i < count; i++ {
buf.Reset()
fmt.Fprintf(buf, "Item %d", i)
total += buf.Len()
}
return total
}
func main() {
start := time.Now()
for i := 0; i < 100000; i++ {
processLoopNaive(100)
}
fmt.Printf("Naive: %v\n", time.Since(start))
start = time.Now()
for i := 0; i < 100000; i++ {
processLoopOptimized(100)
}
fmt.Printf("Optimized: %v\n", time.Since(start))
}
A versão otimizada aloca uma única vez, depois reutiliza o buffer com Reset().
Selecionando tipos de dados apropriados
Nem sempre []byte ou string são a melhor escolha. Arrays de ponteiros gastam mais memória que arrays de valores, e tipos específicos economizam espaço:
package main
import (
"fmt"
"unsafe"
)
// Tipo 1: usando interface{} (gasta memória extra)
func processWithInterface(data []interface{}) {
total := int64(0)
for _, v := range data {
if n, ok := v.(int); ok {
total += int64(n)
}
}
fmt.Println("Interface:", total)
}
// Tipo 2: usando tipo específico (eficiente)
func processWithType(data []int) {
total := int64(0)
for _, v := range data {
total += int64(v)
}
fmt.Println("Type:", total)
}
func main() {
size := 1000000
// Cria dados como interface{}
dataInterface := make([]interface{}, size)
for i := 0; i < size; i++ {
dataInterface[i] = i
}
fmt.Printf("interface{} size: %d bytes per element\n", unsafe.Sizeof(dataInterface[0]))
// Cria dados como int
dataInt := make([]int, size)
for i := 0; i < size; i++ {
dataInt[i] = i
}
fmt.Printf("int size: %d bytes per element\n", unsafe.Sizeof(dataInt[0]))
processWithInterface(dataInterface)
processWithType(dataInt)
}
Tipos específicos economizam memória porque não requerem indireção nem informação de tipo em runtime.
Ferramentas Avançadas e Monitoramento Contínuo
Exportando e visualizando perfis com graphviz
Perfis podem ser convertidos em gráficos visuais que mostram relacionamentos entre funções e custos:
# Capturar heap durante 30 segundos
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap
# Ou com arquivo já salvo
go tool pprof -http=:8081 heap.prof
A opção -http abre uma interface web interativa que mostra flame graphs (gráficos de chama), que são excelentes para visualizar onde a memória é consumida.
Comparando perfis para rastrear regressões
Ao otimizar, é útil comparar perfis antes e depois. Go suporta comparação de perfis:
# Capturar baseline
go tool pprof -base=heap_before.prof http://localhost:6060/debug/pprof/heap > diff.txt
# Ver diferenças
go tool pprof heap_before.prof heap_after.prof
Monitoramento de metavas GC durante execução
Go expõe métricas de GC que ajudam a entender pressão de memória:
package main
import (
"fmt"
"log"
"net/http"
_ "net/http/pprof"
"runtime"
"time"
)
func monitorGC() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
var lastNum uint32
for range ticker.C {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %v MB | ", m.Alloc/1024/1024)
fmt.Printf("TotalAlloc: %v MB | ", m.TotalAlloc/1024/1024)
fmt.Printf("Sys: %v MB | ", m.Sys/1024/1024)
fmt.Printf("NumGC: %v", m.NumGC)
if m.NumGC != lastNum {
fmt.Printf(" (GC ocorreu)\n")
lastNum = m.NumGC
} else {
fmt.Printf("\n")
}
}
}
func workload() {
for i := 0; i < 100000; i++ {
_ = make([]byte, 100)
}
}
func main() {
go monitorGC()
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
for {
workload()
time.Sleep(1 * time.Second)
}
}
Este programa mostra quanto garbage collector está trabalhando. Se NumGC cresce rapidamente, seu código aloca demais.
Conclusão
Profiling de memória em Go não é opcional — é uma habilidade essencial para código produção. Três aprendizados principais o levarão longe: primeiro, use net/http/pprof e go tool pprof para visualizar exatamente onde memória é consumida em tempo real; segundo, aplique técnicas como sync.Pool e pré-alocação apenas onde dados concretos mostram que são necessárias (premature optimization é o inimigo); terceiro, monitore métricas de GC durante execução para detectar regressões antes que atinjam produção. O profiling não é uma tarefa única — é um processo contínuo de medição, otimização e validação.