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.
