Profiling en Go con pprof: encuentra cuellos de botella en CPU, memoria y goroutine
1. Tổng quan
Cuando un servicio de Go se ejecuta lentamente o consume mucha RAM, la suposición aleatoria generalmente consume tiempo y es propensa a errores. Go tiene la herramienta pprof integrada en la biblioteca estándar. Esta herramienta te dice exactamente qué función consume CPU, dónde se asigna memoria y qué goroutine está atascada.
Después de leer este artículo, sabrás cómo habilitar pprof para un servicio HTTP, recopilar perfiles desde benchmarks y programas CLI, leer resultados usando go tool pprof, y comparar dos perfiles para verificar una optimización.
2. Qué es pprof y qué tipos de perfiles hay
pprof tiene dos partes:
- Lado de recopilación: paquete
runtime/pprofynet/http/pprofen la biblioteca estándar. Registran perfiles en formato protobuf. - Lado de análisis: comando
go tool pprof. Este comando lee archivos de perfil o URLs y muestra resultados en forma de tabla, código fuente o interfaz web.
Tipos de perfiles comúnmente utilizados:
| Perfil | Mide qué | ¿Habilitado por defecto? |
|---|---|---|
profile (CPU) |
Tiempo de CPU por función, muestreo 100 veces/seg | Sí, cuando se solicita |
heap |
Memoria viva y total asignada | Sí |
allocs |
Como heap, pero por defecto ve la asignación total |
Sí |
goroutine |
Stack trace de todas las goroutines actuales | Sí |
block |
Tiempo de espera en canal, select, sync.Cond |
No |
mutex |
Contención en sync.Mutex/RWMutex |
No |
trace |
Evento trace detallado (usar go tool trace) |
Sí, cuando se solicita |
3. Habilitar pprof para servicios HTTP
La forma más rápida es importar net/http/pprof para obtener el efecto secundario. Este paquete registra los handlers /debug/pprof/* en http.DefaultServeMux.
package main
import (
"log"
"net/http"
_ "net/http/pprof" // registra /debug/pprof/* en DefaultServeMux
)
func main() {
// Servidor pprof separado, escucha solo en localhost
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Servidor de aplicación usa mux separado, no expone pprof
appMux := http.NewServeMux()
appMux.HandleFunc("GET /invoice", invoiceHandler)
log.Fatal(http.ListenAndServe(":8080", appMux))
}
Algunos puntos a tener en cuenta:
- La línea
_ "net/http/pprof"solo registra handlers, no llamas a ninguna función en este paquete. - El servidor pprof pasa
nilcomo handler, es decir, usaDefaultServeMux, que contiene las rutas pprof. - La aplicación principal usa
appMuxseparado, por lo que el puerto8080no tiene/debug/pprof.
Verificación rápida:
curl -s http://localhost:6060/debug/pprof/ | head -n 20
Nota: Nunca expongas
/debug/pprofa Internet. Este endpoint expone stack traces y la estructura del código. Los atacantes también pueden usarlo para sobrecargar el servicio (el perfil de CPU durante 30 segundos consume recursos). Vincula alocalhosto colócalo detrás de una red interna y una capa de autenticación.
4. Recopilar y leer perfil de CPU
Supongamos que el servicio tiene una función para crear facturas así:
type Item struct {
SKU string
Price float64
Qty int
}
type Order struct {
ID int
Items []Item
}
func BuildInvoice(o Order) string {
s := ""
for _, it := range o.Items {
s += fmt.Sprintf("%s x%d = %.2f\n", it.SKU, it.Qty, it.Price*float64(it.Qty))
}
return s
}
En cada iteración, s += ... crea una nueva cadena y copia todo el contenido anterior. Con pedidos con muchas líneas, el costo aumenta en O(n²).
Mientras el servicio recibe carga (por ejemplo, usas hey o wrk para enviar solicitudes a /invoice), recopila el perfil de CPU durante 30 segundos:
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=30"
Después de recopilar, entras en modo interactivo. Escribe top:
(pprof) top 8
Showing nodes accounting for 2.41s, 85.46% of 2.82s total
flat flat% sum% cum cum%
0.92s 32.62% 32.62% 0.92s 32.62% runtime.memmove
0.41s 14.54% 47.16% 1.38s 48.94% runtime.concatstrings
0.33s 11.70% 58.87% 0.33s 11.70% runtime.memclrNoHeapPointers
0.27s 9.57% 68.44% 0.71s 25.18% runtime.mallocgc
0.18s 6.38% 74.82% 0.52s 18.44% fmt.(*pp).doPrintf
0.13s 4.61% 79.43% 0.13s 4.61% strconv.fmtF
0.10s 3.55% 82.98% 2.47s 87.59% main.BuildInvoice
0.07s 2.48% 85.46% 0.21s 7.45% runtime.scanobject
Cómo leer las dos columnas más importantes:
- flat: tiempo de CPU que está directamente dentro de esa función.
- cum (acumulativo): tiempo de esa función más todas las funciones que llama.
Aquí main.BuildInvoice tiene flat pequeño pero cum cerca del 88%. La mayoría del tiempo se gasta en memmove y concatstrings, es decir, la concatenación de cadenas. Para ver detalles línea por línea, usa list:
(pprof) list BuildInvoice
ROUTINE ======================== main.BuildInvoice in /app/invoice.go
100ms 2.47s (flat, cum) 87.59% of Total
. . 18:func BuildInvoice(o Order) string {
. . 19: s := ""
10ms 10ms 20: for _, it := range o.Items {
90ms 2.46s 21: s += fmt.Sprintf("%s x%d = %.2f\n", ...)
. . 22: }
La línea 21 es el culpable. Si quieres ver un gráfico de llamas, abre la interfaz web:
go tool pprof -http=:8081 "http://localhost:6060/debug/pprof/profile?seconds=30"
El navegador abrirá vistas de Top, Graph, Flame Graph y Source. En el gráfico de llamas, los bloques más anchos consumen más CPU.
Solución usando strings.Builder y fmt.Fprintf:
func BuildInvoiceFast(o Order) string {
var b strings.Builder
b.Grow(len(o.Items) * 32) // estima de antemano, evita crecer muchas veces
for _, it := range o.Items {
fmt.Fprintf(&b, "%s x%d = %.2f\n", it.SKU, it.Qty, it.Price*float64(it.Qty))
}
return b.String()
}
5. Perfil de heap: ¿quién está retiendo memoria?
go tool pprof http://localhost:6060/debug/pprof/heap
Un perfil de heap contiene cuatro “índices de muestra”. Seleccionas el índice con la bandera -sample_index:
inuse_space(predeterminado): número de bytes vivos actualmente en el momento del GC más reciente. Úsalo para encontrar memory leaks.inuse_objects: número de objetos vivos.alloc_space: total de bytes asignados desde que se inició el programa. Úsalo para encontrar lo que presiona el GC.alloc_objects: número total de objetos asignados.
go tool pprof -sample_index=alloc_space http://localhost:6060/debug/pprof/heap
(pprof) top 3
flat flat% sum% cum cum%
1.84GB 91.20% 91.20% 1.84GB 91.20% main.BuildInvoice
0.12GB 5.95% 97.15% 0.12GB 5.95% fmt.Sprintf
0.03GB 1.49% 98.64% 0.03GB 1.49% encoding/json.Marshal
El resultado muestra que BuildInvoice ha asignado 1,84 GB en total, aunque la cantidad viva puede ser muy pequeña. Esto es una señal de que el GC tiene que trabajar mucho. Esta situación coincide con la cantidad de runtime.mallocgc y runtime.scanobject que ves en el perfil de CPU en la sección 4.
Nota: El perfil de heap también es un muestreo (por defecto alrededor de 1 muestra cada 512KB asignados), por lo que los números son solo estimaciones. No compares byte por byte, mira los porcentajes.
6. Perfil de goroutine: detectar goroutine leaks
Si el número de goroutines solo sigue aumentando, puedes echar un vistazo rápido a través del navegador o curl sin necesidad de go tool pprof:
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" | head -n 15
goroutine profile: total 10432
10398 @ 0x43a5d6 0x4071c5 0x406d78 0x6b2f45 0x46c0e1
# 0x6b2f44 main.(*PaymentService).notify+0x64 /app/payment.go:57
10.398 goroutines están atrapadas en payment.go:57. Esto es casi seguro un leak. El código que causa el error generalmente se ve así:
func (s *PaymentService) notify(ctx context.Context, p Payment) {
result := make(chan error) // sin búfer
go func() {
result <- s.gateway.Send(p) // atrapada para siempre si nadie recibe
}()
select {
case err := <-result:
log.Println(err)
case <-ctx.Done():
return // goroutine interna se queda atrás
}
}
Cuando ctx expira primero, nadie más lee result, así que la goroutine interna se queda atrapada permanentemente en el envío. La solución simple es usar make(chan error, 1) para que el envío nunca se bloquee.
Con debug=2, el endpoint imprime el stack trace completo de cada goroutine, incluyendo el tiempo de espera (por ejemplo, [chan send, 42 minutes]). Esta información es muy útil al investigar deadlocks.
7. Perfil de block y mutex
Estos dos perfiles están deshabilitados por defecto porque tienen costo. Los habilitas en main:
import "runtime"
func init() {
// Registra en promedio 1 evento de contencion de mutex de cada 5
runtime.SetMutexProfileFraction(5)
// Muestrea 1 evento de bloqueo por cada 10µs bloqueados
runtime.SetBlockProfileRate(10_000)
}
Después recopila el perfil como de costumbre:
go tool pprof http://localhost:6060/debug/pprof/mutex
go tool pprof http://localhost:6060/debug/pprof/block
El perfil de mutex te dice qué lock hace que las goroutines esperen en cola, por ejemplo un sync.Mutex protegiendo un caché compartido map[int]Customer. El perfil de block te dice dónde las goroutines esperan en un canal o select.
Nota:
SetBlockProfileRate(1)registra cada evento de bloqueo. Este nivel solo es apropiado para depuración local. En producción, usa una tasa mayor o habilita solo temporalmente.
8. Profiling desde benchmark
Para una función específica, la forma más concisa es escribir un benchmark y dejar que go test genere el perfil. El código a continuación usa b.Loop() que requiere Go 1.24 o superior.
package main
import (
"fmt"
"testing"
)
func sampleOrder(n int) Order {
items := make([]Item, n)
for i := range items {
items[i] = Item{SKU: fmt.Sprintf("SKU-%04d", i), Price: 19.99, Qty: i%5 + 1}
}
return Order{ID: 1, Items: items}
}
func TestBuildInvoiceFastMatchesOriginal(t *testing.T) {
order := sampleOrder(200)
if got, want := BuildInvoiceFast(order), BuildInvoice(order); got != want {
t.Fatalf("salida diferente:\n got=%q\nwant=%q", got, want)
}
}
func BenchmarkBuildInvoice(b *testing.B) {
order := sampleOrder(500)
b.ReportAllocs()
for b.Loop() {
BuildInvoice(order)
}
}
func BenchmarkBuildInvoiceFast(b *testing.B) {
order := sampleOrder(500)
b.ReportAllocs()
for b.Loop() {
BuildInvoiceFast(order)
}
}
El test TestBuildInvoiceFastMatchesOriginal asegura que la versión optimizada produzca exactamente el mismo resultado que la versión anterior. No omitas este paso: optimizar pero producir resultados incorrectos es inútil. Ejecuta el benchmark con perfil:
go test -run='^$' -bench=BuildInvoice -cpuprofile=cpu.out -memprofile=mem.out
BenchmarkBuildInvoice-8 1543 772410 ns/op 6342817 B/op 1002 allocs/op
BenchmarkBuildInvoiceFast-8 19821 60318 ns/op 36472 B/op 504 allocs/op
La versión que usa strings.Builder es aproximadamente 12 veces más rápida y asigna aproximadamente 170 veces menos. La bandera -run='^$' salta las pruebas normales para ejecutar solo los benchmarks. Los archivos cpu.out y mem.out se abren con:
go tool pprof -http=:8081 cpu.out
9. Profiling de un programa CLI con runtime/pprof
Para una herramienta que se ejecuta una sola vez y luego sale, como un trabajo de importación de pedidos, usas directamente runtime/pprof:
package main
import (
"log"
"os"
"runtime"
"runtime/pprof"
)
func main() {
cpuFile, err := os.Create("cpu.out")
if err != nil {
log.Fatal(err)
}
defer cpuFile.Close()
if err := pprof.StartCPUProfile(cpuFile); err != nil {
log.Fatal(err)
}
defer pprof.StopCPUProfile()
importOrders("orders.csv") // trabajo principal
memFile, err := os.Create("mem.out")
if err != nil {
log.Fatal(err)
}
defer memFile.Close()
runtime.GC() // actualiza estadísticas inuse antes de escribir
if err := pprof.WriteHeapProfile(memFile); err != nil {
log.Fatal(err)
}
}
Dos errores comunes aquí:
- Llamar a
log.Fataluos.Exitdespués deStartCPUProfileomitirá losdefer. En este caso,StopCPUProfileno se ejecuta y el archivo de perfil está vacío o corrupto. - Olvidar
runtime.GC()antes deWriteHeapProfile. Las estadísticas deinuse_*solo se actualizan después de cada GC, por lo que el perfil puede reflejar un estado antiguo.
10. Comparar dos perfiles
Después de optimizar, deberías medir nuevamente en lugar de confiar en tu intuición. La bandera -diff_base resta el perfil anterior del nuevo:
go tool pprof -diff_base=cpu_before.out cpu_after.out
(pprof) top 3
flat flat% sum% cum cum%
-0.89s 31.56% 31.56% -0.89s 31.56% runtime.memmove
-0.40s 14.18% 45.74% -1.35s 47.87% runtime.concatstrings
-0.25s 8.87% 54.61% -0.66s 23.40% runtime.mallocgc
Los valores negativos significan que el tiempo ha disminuido. Para que los resultados sean significativos, los dos perfiles deben recopilarse bajo las mismas condiciones: misma carga, misma duración, misma máquina.
Además, desde Go 1.21, el perfil de CPU también sirve para Profile-Guided Optimization (PGO). Recopila un perfil de CPU representativo de producción, llámalo default.pgo en el directorio del paquete main, y go build lo usará automáticamente para inline y optimizar los caminos de código caliente. La mejora generalmente es de algunos puntos porcentuales, y no necesitas cambiar ninguna línea de código.
11. Errores comunes y cuándo no usar
- Perfil demasiado corto o sin carga. El perfil de CPU muestrea a 100Hz. Si solo recopila 2 segundos cuando el servicio está inactivo, solo verá
runtimey ruido. - Confundir
flatconcum. Una función concumalto no es necesariamente el culpable, podría simplemente estar llamando al culpable. - Confundir
inuse_spaceconalloc_space. Para encontrar leaks, mirainuse. Para reducir la presión de GC, miraalloc. - Medir I/O con perfil de CPU. El tiempo que las goroutines esperan en la base de datos o HTTP no consume CPU, por lo que no aparece en el perfil de CPU. Usa el perfil de block,
go tool traceo trazado distribuido. - Dejar pprof público en el puerto de la aplicación. Ver sección 3.
Nota: pprof responde a la pregunta “dónde se gasta”, no “¿debería optimizar esto?”. Si un endpoint ya cumple con el SLA y una función solo consume el 2% de CPU, reescribirla para hacerla más compleja generalmente no vale la pena.
12. Conclusión
En este artículo has:
- habilitado
net/http/pprofde forma segura en un servidor separado enlocalhost; - leído el perfil de CPU con
topylist, y distinguidoflatdecum; - usado el perfil de heap con
inuse_spaceyalloc_space; - detectado goroutine leaks a través de
debug=1ydebug=2; - habilitado perfiles de block y mutex;
- generado perfiles desde benchmarks y programas CLI;
- comparado antes/después usando
-diff_base.
El proceso más efectivo sigue siendo: medir → encontrar puntos calientes → arreglar → escribir pruebas para mantener el comportamiento correcto → medir de nuevo. Con pprof disponible en la biblioteca estándar, no necesitas instalar nada para comenzar.