Profiling en Go: pprof, benchmarks, escape analysis y optimización de memoria

Go incluye en su toolchain las herramientas necesarias para encontrar cuellos de botella sin dependencias externas: pprof para profiles de CPU y memoria, testing.B para benchmarks reproducibles y el flag -gcflags="-m" para ver qué variables escapan al heap. Con estas tres herramientas puedes reducir alocaciones, aliviar la presión del GC y optimizar las rutas críticas de tu aplicación.

pprof: perfilado de CPU

Añade el endpoint de pprof a tu servidor HTTP con un import de efecto secundario:

package main

import (
    "net/http"
    _ "net/http/pprof" // registra /debug/pprof automáticamente
)

func main() {
    http.ListenAndServe(":8080", nil)
}
// Capturar un profile de CPU durante 30 segundos
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30

// En la shell interactiva de pprof:
(pprof) top10      // las 10 funciones que más CPU consumen
(pprof) web        // abrir en el navegador (requiere graphviz)
(pprof) list miFunc // ver el código fuente anotado con tiempos

pprof para memoria: heap profiles

// Profile de memoria en uso
go tool pprof http://localhost:8080/debug/pprof/heap

// Comparar dos profiles (antes y después de un cambio)
go tool pprof -base profile_antes.pprof profile_despues.pprof
(pprof) top   // ver qué funciones alocaron más memoria

runtime/pprof en aplicaciones CLI

package main

import (
    "os"
    "runtime/pprof"
)

func main() {
    // Profile de CPU
    f, _ := os.Create("cpu.pprof")
    pprof.StartCPUProfile(f)
    defer pprof.StopCPUProfile()

    // Profile de memoria (al final del programa)
    defer func() {
        fm, _ := os.Create("mem.pprof")
        pprof.WriteHeapProfile(fm)
        fm.Close()
    }()

    // Tu lógica aquí...
    procesarDatos()
}

Benchmarks con memoria

-benchmem añade a la salida del benchmark el número de alocaciones y los bytes por operación, lo más útil para identificar regresiones de memoria:

func BenchmarkConcatenar(b *testing.B) {
    partes := []string{"Go", "es", "rápido", "y", "simple"}
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        _ = strings.Join(partes, " ")
    }
}

// go test -bench=BenchmarkConcatenar -benchmem -count=5
// BenchmarkConcatenar-8   5000000   280 ns/op   32 B/op   1 allocs/op

sync.Pool: reutilizar buffers y reducir alocaciones

var bufPool = &sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

// SIN pool: cada llamada aloca un Buffer nuevo
func renderizarSinPool(tmpl *template.Template, datos any) ([]byte, error) {
    var buf bytes.Buffer
    if err := tmpl.Execute(&buf, datos); err != nil {
        return nil, err
    }
    return buf.Bytes(), nil
}

// CON pool: reutiliza el Buffer, mucho menos presión en el GC
func renderizarConPool(tmpl *template.Template, datos any) ([]byte, error) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    if err := tmpl.Execute(buf, datos); err != nil {
        return nil, err
    }
    // Copiar el resultado antes de devolver el buffer al pool
    resultado := make([]byte, buf.Len())
    copy(resultado, buf.Bytes())
    return resultado, nil
}

strings.Builder vs concatenación con +

// MAL: aloca un nuevo string en cada iteración
func unirMal(partes []string) string {
    resultado := ""
    for _, p := range partes {
        resultado += p + ", " // O(n²) alocaciones
    }
    return resultado
}

// BIEN: una sola alocación con preasignación
func unirBien(partes []string) string {
    var sb strings.Builder
    sb.Grow(len(partes) * 10) // preasignar capacidad estimada
    for i, p := range partes {
        if i > 0 {
            sb.WriteString(", ")
        }
        sb.WriteString(p)
    }
    return sb.String()
}

Escape analysis: qué va al heap

El compilador de Go decide automáticamente si una variable vive en el stack (rápido, sin GC) o en el heap (necesita GC). Con -gcflags="-m" puedes ver esas decisiones:

// go build -gcflags="-m" ./...
// ./main.go:15:12: nueva variable escapa al heap
// ./main.go:23:2: buffer no escapa
// Una variable escapa al heap cuando:
// 1. Se devuelve su dirección desde una función
func nueva() *Dato {
    d := Dato{} // escapa: la dirección sale de la función
    return &d
}

// 2. Se almacena en una interfaz
var i interface{} = Dato{} // el valor concreto escapa

// 3. El compilador no puede determinar el tamaño en tiempo de compilación
func hacerSlice(n int) []int {
    return make([]int, n) // el slice escapa si n no es constante
}

Cuándo optimizar

El proceso recomendado es: medir primero con benchmarks y pprof, identificar el cuello de botella real, optimizar solo esa parte y volver a medir para confirmar la mejora. Optimizar sin datos lleva a invertir tiempo en partes del código que no importan para el rendimiento general.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP