Go

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

16 min de leitura

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

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

<h2>Introdução ao Profiling de Memória em Go</h2>

<p>Profiling é a análise sistemática de como seu programa utiliza recursos, especialmente memória. Em Go, o pacote <code>runtime/pprof</code> 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.</p>

<p>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.</p>

<h2>Fundamentos do pprof e Coleta de Dados</h2>

<h3>O que é pprof e como funciona</h3>

<p>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.</p>

<p>Go fornece vários perfis: <strong>heap</strong> (alocações de memória), <strong>goroutine</strong> (número de goroutines ativas), <strong>threadcreate</strong> (criação de threads) e <strong>allocs</strong> (todas as alocações). O perfil de heap é o mais importante para otimizações.</p>

<h3>Habilitando pprof com net/http/pprof</h3>

<p>A forma mais prática de iniciar profiling em uma aplicação Go é através do servidor HTTP integrado. Quando você importa <code>_ &quot;net/http/pprof&quot;</code>, Go registra automaticamente endpoints que expõem os perfis:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;log&quot;

&quot;net/http&quot;

_ &quot;net/http/pprof&quot;

&quot;time&quot;

)

func processData() {

// Simula processamento que aloca muita memória

for i := 0; i &lt; 1000; i++ {

data := make([]byte, 1024*1024) // Aloca 1 MB

_ = data

time.Sleep(10 * time.Millisecond)

}

}

func main() {

go func() {

log.Println(http.ListenAndServe(&quot;localhost:6060&quot;, nil))

}()

fmt.Println(&quot;Profiler disponível em http://localhost:6060/debug/pprof/&quot;)

processData()

}</code></pre>

<p>Ao rodar este programa, você acessa <code>http://localhost:6060/debug/pprof/</code> para visualizar todos os perfis disponíveis. O endpoint <code>/debug/pprof/heap</code> fornece o heap profile atual.</p>

<h3>Coleta programática de perfis</h3>

<p>Para aplicações sem servidor HTTP (como CLIs ou workers), você pode coletar perfis diretamente usando <code>os.Create</code> e <code>pprof.WriteHeapProfile</code>:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;os&quot;

&quot;runtime/pprof&quot;

)

func processData() {

// Simula picos de alocação

for i := 0; i &lt; 500; i++ {

largeSlice := make([]string, 10000)

for j := range largeSlice {

largeSlice[j] = fmt.Sprintf(&quot;item_%d&quot;, j)

}

}

}

func main() {

// Criar arquivo para armazenar o heap profile

f, err := os.Create(&quot;heap.prof&quot;)

if err != nil {

panic(err)

}

defer f.Close()

// Forçar garbage collection para limpeza

defer pprof.WriteHeapProfile(f)

processData()

fmt.Println(&quot;Heap profile salvo em heap.prof&quot;)

}</code></pre>

<p>Após executar este programa, você terá um arquivo <code>heap.prof</code> que pode ser analisado com ferramentas como <code>go tool pprof</code>.</p>

<h2>Analisando Heap Dumps e Identificando Vazamentos</h2>

<h3>Interpretando o heap profile</h3>

<p>Um heap dump captura o estado da memória em um momento específico. Para análise visual e interativa, use:</p>

<pre><code class="language-bash">go tool pprof http://localhost:6060/debug/pprof/heap</code></pre>

<p>Ou com arquivo salvo:</p>

<pre><code class="language-bash">go tool pprof heap.prof</code></pre>

<p>Dentro do pprof, você encontra comandos úteis:</p>

<ul>

<li><code>top</code>: lista as funções que mais allocam memória</li>

<li><code>list &lt;função&gt;</code>: mostra o código-fonte com alocações por linha</li>

<li><code>web</code>: gera gráfico visual (requer Graphviz)</li>

<li><code>alloc_space</code>: total alocado desde o início (inclui coletado)</li>

<li><code>alloc_objects</code>: número de objetos alocados</li>

</ul>

<h3>Exemplo prático: detectando vazamento de memória</h3>

<p>Considere um servidor que processa requisições e acumula dados sem limpeza:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;log&quot;

&quot;net/http&quot;

_ &quot;net/http/pprof&quot;

&quot;sync&quot;

&quot;time&quot;

)

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(&quot;key&quot;)

cacheMutex.Lock()

// Cada requisição adiciona 10 MB ao cache sem remover antigos

cache[key] = make([]byte, 1010241024)

cacheMutex.Unlock()

fmt.Fprintf(w, &quot;Cached %s\n&quot;, key)

}

func main() {

go func() {

log.Println(http.ListenAndServe(&quot;localhost:6060&quot;, nil))

}()

http.HandleFunc(&quot;/request&quot;, handleRequest)

go func() {

// Simula requisições contínuas

for i := 0; i &lt; 100; i++ {

http.Get(fmt.Sprintf(&quot;http://localhost:6060/request?key=item_%d&quot;, i))

time.Sleep(100 * time.Millisecond)

}

}()

log.Println(http.ListenAndServe(&quot;localhost:8080&quot;, nil))

}</code></pre>

<p>Ao acessar <code>/debug/pprof/heap</code>, você verá que <code>handleRequest</code> cresce indefinidamente. A solução é implementar limpeza:</p>

<pre><code class="language-go">func handleRequest(w http.ResponseWriter, r *http.Request) {

key := r.URL.Query().Get(&quot;key&quot;)

cacheMutex.Lock()

// Limita tamanho máximo do cache

if len(cache) &gt; 1000 {

// Remove item mais antigo (simplificado)

for k := range cache {

delete(cache, k)

break

}

}

cache[key] = make([]byte, 1010241024)

cacheMutex.Unlock()

fmt.Fprintf(w, &quot;Cached %s\n&quot;, key)

}</code></pre>

<h3>Gerando perfis com CPU para contexto</h3>

<p>Às vezes, entender uso de memória requer contexto de CPU. Você pode combinar perfis:</p>

<pre><code class="language-go">package main

import (

&quot;log&quot;

&quot;net/http&quot;

_ &quot;net/http/pprof&quot;

&quot;os&quot;

&quot;runtime&quot;

&quot;runtime/pprof&quot;

&quot;sync&quot;

)

func expensiveComputation() {

var wg sync.WaitGroup

for i := 0; i &lt; 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(&quot;cpu.prof&quot;)

defer cpuProfile.Close()

pprof.StartCPUProfile(cpuProfile)

defer pprof.StopCPUProfile()

memProfile, _ := os.Create(&quot;mem.prof&quot;)

defer memProfile.Close()

defer pprof.WriteHeapProfile(memProfile)

expensiveComputation()

// Força GC antes de escrever heap

runtime.GC()

log.Println(&quot;Perfis salvos: cpu.prof e mem.prof&quot;)

}</code></pre>

<h2>Otimizações Práticas de Memória</h2>

<h3>Object pooling e sync.Pool</h3>

<p>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 <code>sync.Pool</code>:</p>

<pre><code class="language-go">package main

import (

&quot;bytes&quot;

&quot;fmt&quot;

&quot;sync&quot;

&quot;time&quot;

)

type RequestBuffer struct {

Data *bytes.Buffer

Count int

}

// Pool reutiliza buffers em vez de alocar novos

var bufferPool = sync.Pool{

New: func() interface{} {

return &amp;RequestBuffer{

Data: &amp;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 &lt; 100; i++ {

fmt.Fprintf(buf.Data, &quot;Line %d\n&quot;, i)

buf.Count++

}

// Resultado fica no buffer reutilizável

}

func main() {

start := time.Now()

for i := 0; i &lt; 10000; i++ {

processRequest(i)

}

fmt.Printf(&quot;Tempo: %v\n&quot;, time.Since(start))

}</code></pre>

<p>Com <code>sync.Pool</code>, 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.</p>

<h3>Pré-alocação e evitar realocações</h3>

<p>Slices em Go crescem dinamicamente, mas cada realocação copia dados. Quando você sabe o tamanho aproximado, pré-alocar economiza memória e CPU:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;time&quot;

)

// Ineficiente: cresce dinamicamente

func buildSliceNaive(size int) []int {

var result []int

for i := 0; i &lt; 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 &lt; 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 &lt; 10000; i++ {

fn(size)

}

fmt.Printf(&quot;%s: %v\n&quot;, name, time.Since(start))

}

func main() {

benchmark(&quot;Naive&quot;, buildSliceNaive, 1000)

benchmark(&quot;Optimized&quot;, buildSliceOptimized, 1000)

}</code></pre>

<p>A versão otimizada aloca uma única vez com capacidade exata, evitando realocações quando append é chamado.</p>

<h3>Reduzindo alocações em loops críticos</h3>

<p>Loops que executam milhões de vezes precisam alocar minimamente. Use variáveis locais reutilizáveis:</p>

<pre><code class="language-go">package main

import (

&quot;bytes&quot;

&quot;fmt&quot;

&quot;time&quot;

)

// Aloca buffer a cada iteração (RUIM)

func processLoopNaive(count int) int {

total := 0

for i := 0; i &lt; count; i++ {

buf := &amp;bytes.Buffer{}

fmt.Fprintf(buf, &quot;Item %d&quot;, i)

total += buf.Len()

}

return total

}

// Reutiliza buffer (BOM)

func processLoopOptimized(count int) int {

total := 0

buf := &amp;bytes.Buffer{}

for i := 0; i &lt; count; i++ {

buf.Reset()

fmt.Fprintf(buf, &quot;Item %d&quot;, i)

total += buf.Len()

}

return total

}

func main() {

start := time.Now()

for i := 0; i &lt; 100000; i++ {

processLoopNaive(100)

}

fmt.Printf(&quot;Naive: %v\n&quot;, time.Since(start))

start = time.Now()

for i := 0; i &lt; 100000; i++ {

processLoopOptimized(100)

}

fmt.Printf(&quot;Optimized: %v\n&quot;, time.Since(start))

}</code></pre>

<p>A versão otimizada aloca uma única vez, depois reutiliza o buffer com <code>Reset()</code>.</p>

<h3>Selecionando tipos de dados apropriados</h3>

<p>Nem sempre <code>[]byte</code> ou <code>string</code> são a melhor escolha. Arrays de ponteiros gastam mais memória que arrays de valores, e tipos específicos economizam espaço:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;unsafe&quot;

)

// 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(&quot;Interface:&quot;, total)

}

// Tipo 2: usando tipo específico (eficiente)

func processWithType(data []int) {

total := int64(0)

for _, v := range data {

total += int64(v)

}

fmt.Println(&quot;Type:&quot;, total)

}

func main() {

size := 1000000

// Cria dados como interface{}

dataInterface := make([]interface{}, size)

for i := 0; i &lt; size; i++ {

dataInterface[i] = i

}

fmt.Printf(&quot;interface{} size: %d bytes per element\n&quot;, unsafe.Sizeof(dataInterface[0]))

// Cria dados como int

dataInt := make([]int, size)

for i := 0; i &lt; size; i++ {

dataInt[i] = i

}

fmt.Printf(&quot;int size: %d bytes per element\n&quot;, unsafe.Sizeof(dataInt[0]))

processWithInterface(dataInterface)

processWithType(dataInt)

}</code></pre>

<p>Tipos específicos economizam memória porque não requerem indireção nem informação de tipo em runtime.</p>

<h2>Ferramentas Avançadas e Monitoramento Contínuo</h2>

<h3>Exportando e visualizando perfis com graphviz</h3>

<p>Perfis podem ser convertidos em gráficos visuais que mostram relacionamentos entre funções e custos:</p>

<pre><code class="language-bash"># 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</code></pre>

<p>A opção <code>-http</code> abre uma interface web interativa que mostra flame graphs (gráficos de chama), que são excelentes para visualizar onde a memória é consumida.</p>

<h3>Comparando perfis para rastrear regressões</h3>

<p>Ao otimizar, é útil comparar perfis antes e depois. Go suporta comparação de perfis:</p>

<pre><code class="language-bash"># Capturar baseline

go tool pprof -base=heap_before.prof http://localhost:6060/debug/pprof/heap &gt; diff.txt

Ver diferenças

go tool pprof heap_before.prof heap_after.prof</code></pre>

<h3>Monitoramento de metavas GC durante execução</h3>

<p>Go expõe métricas de GC que ajudam a entender pressão de memória:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;log&quot;

&quot;net/http&quot;

_ &quot;net/http/pprof&quot;

&quot;runtime&quot;

&quot;time&quot;

)

func monitorGC() {

ticker := time.NewTicker(5 * time.Second)

defer ticker.Stop()

var lastNum uint32

for range ticker.C {

var m runtime.MemStats

runtime.ReadMemStats(&amp;m)

fmt.Printf(&quot;Alloc: %v MB | &quot;, m.Alloc/1024/1024) fmt.Printf(&quot;TotalAlloc: %v MB | &quot;, m.TotalAlloc/1024/1024) fmt.Printf(&quot;Sys: %v MB | &quot;, m.Sys/1024/1024)

fmt.Printf(&quot;NumGC: %v&quot;, m.NumGC)

if m.NumGC != lastNum {

fmt.Printf(&quot; (GC ocorreu)\n&quot;)

lastNum = m.NumGC

} else {

fmt.Printf(&quot;\n&quot;)

}

}

}

func workload() {

for i := 0; i &lt; 100000; i++ {

_ = make([]byte, 100)

}

}

func main() {

go monitorGC()

go func() {

log.Println(http.ListenAndServe(&quot;localhost:6060&quot;, nil))

}()

for {

workload()

time.Sleep(1 * time.Second)

}

}</code></pre>

<p>Este programa mostra quanto garbage collector está trabalhando. Se <code>NumGC</code> cresce rapidamente, seu código aloca demais.</p>

<h2>Conclusão</h2>

<p>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, <strong>use <code>net/http/pprof</code> e <code>go tool pprof</code></strong> para visualizar exatamente onde memória é consumida em tempo real; segundo, <strong>aplique técnicas como <code>sync.Pool</code> e pré-alocação</strong> apenas onde dados concretos mostram que são necessárias (premature optimization é o inimigo); terceiro, <strong>monitore métricas de GC durante execução</strong> 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.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://pkg.go.dev/runtime/pprof" target="_blank" rel="noopener noreferrer">Documentação oficial: runtime/pprof</a></li>

<li><a href="https://pkg.go.dev/net/http/pprof" target="_blank" rel="noopener noreferrer">Documentação oficial: net/http/pprof</a></li>

<li><a href="https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html" target="_blank" rel="noopener noreferrer">Go Memory Management and Garbage Collection</a></li>

<li><a href="https://go.dev/blog/profiling-go-programs" target="_blank" rel="noopener noreferrer">Profiling Go Programs - Go Blog</a></li>

<li><a href="https://dave.cheney.net/high-performance-go-book/8-tips-for-using-the-pprof-debugger.html" target="_blank" rel="noopener noreferrer">High Performance Go - Dave Cheney</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em Go

Guia Completo de Context em Go: Cancelamento, Timeout e Propagação de Valores
Guia Completo de Context em Go: Cancelamento, Timeout e Propagação de Valores

Context em Go: Cancelamento, Timeout e Propagação de Valores O é um dos pacot...

O que Todo Dev Deve Saber sobre Build e Cross-Compilation em Go: Binários para Múltiplas Plataformas
O que Todo Dev Deve Saber sobre Build e Cross-Compilation em Go: Binários para Múltiplas Plataformas

Build e Cross-Compilation em Go: Dominando Binários para Múltiplas Plataforma...

Como Usar database/sql em Go: Conexão, Queries e Boas Práticas Nativas em Produção
Como Usar database/sql em Go: Conexão, Queries e Boas Práticas Nativas em Produção

Fundamentos do Package database/sql O package é a abstração padrão da linguag...