Go Admin

Profiling de Memória em Go: pprof, Heap Dumps e Otimizações: Do Básico ao Avançado Já leu

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 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

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.

Referências


Artigos relacionados