Para optimizar el rendimiento de un servicio en Go, no basta con monitorizar cuánta RAM consume el proceso; hay que entender qué tipo de memoria está consumiendo y cómo se comporta el Garbage Collector (GC). El pprof de memoria es la herramienta que nos permite diseccionar este comportamiento.
Cuando ejecutas un profile de memoria, el runtime de Go no registra cada byte asignado (eso sería un desastre para el rendimiento), sino que utiliza un sistema de muestreo (sampling). Por defecto, el runtime toma una muestra de cada 512 bytes. Si intentas encontrar una fuga de memoria minúscula o una asignación muy pequeña pero extremadamente frecuente, es probable que el sampler la ignore. Para auditorías precisas, debes forzar el muestreo con -memprofilerate=1, aunque esto introduce un overhead considerable en el benchmark.
Para usarlo correctamente, debes distinguir entre dos dimensiones críticas: el ciclo de vida de la memoria y el volumen de la misma. No es lo mismo un programa que pide 1 GB de RAM y la devuelve (alta presión sobre el GC) que uno que pide 10 MB y nunca los devuelve (fuga de memoria).
Si confundes estas métricas, tu optimización será estéril: si usas inuse_space para buscar problemas de latencia causados por el GC, solo verás la memoria que sobrevive, ignorando por completo la “agitación” de objetos que el GC está barriendo constantemente y que es lo que realmente está deteniendo tus goroutines.
package main
import (
"fmt"
"runtime"
)
// Packet representa un objeto que suele circular en sistemas de streaming.
type Packet struct {
Payload []byte
}
// simulateChurn simula la creación de millones de objetos de vida corta.
// Esto genera una alta "churn" o agitación, aumentando el alloc_space.
// El GC tendrá que trabajar muchísimo, aunque el inuse_space sea bajo.
func simulateChurn(iterations int) {
for i := 0; i < iterations; i++ {
// Cada iteración aloca 1KB que muere casi inmediatamente.
_ = Packet{Payload: make([]byte, 1024)}
}
}
// simulateRetention simula una fuga de memoria o una caché mal gestionada.
// Los objetos se mantienen vivos, aumentando el inuse_space.
func simulateRetention(iterations int) [] *Packet {
cache := make([]*Packet, 0, iterations)
for i := 0; i < iterations; i++ {
p := &Packet{Payload: make([]byte, 1024)}
cache = append(cache, p)
}
return cache
}
func main() {
const iteraciones = 500_000
fmt.Println("--- Iniciando simulación de carga ---")
// 1. Generamos agitación (Churn)
// Esto se verá en pprof como un valor alto en 'alloc_space' y 'alloc_objects'.
simulateChurn(iteraciones)
fmt.Println("Simulación de Churn completada.")
// Forzamos GC para limpiar el churn antes de la siguiente fase
runtime.GC()
// 2. Generamos retención (Footprint)
// Esto se verá en pprof como un valor alto en 'inuse_space' y 'inuse_objects'.
cache := simulateRetention(iteraciones)
fmt.Println("Simulación de Retención completada.")
// Evitamos que el programa termine demasiado rápido para inspección manual si fuera necesario
fmt.Printf("Memoria retenida en cache: %d paquetes\n", len(cache))
fmt.Println("--- Proceso finalizado ---")
}
Desglose del perfilado
Para analizar este código, primero debemos generar el perfil. Como el programa es un ejecutable lineal, lo ideal es envolver la lógica en un test de benchmark o usar un servidor HTTP con net/http/pprof. Si usamos go test -bench=. -memprofile=mem.prof, el runtime guardará el estado de la memoria.
Al abrir el perfil con go tool pprof mem.prof, la métrica que elijas cambiará totalmente tu perspectiva:
- Si ejecutas
top -alloc_space, verás quesimulateChurnes la culpable. Te mostrará que se han asignado aproximadamente 500 MB (500,000 * 1KB), aunque el programa casi no esté usando RAM en ese momento. Esto te indica que el problema es la frecuencia de asignación, y la solución sería usar unsync.Poolpara reutilizar los[]byte. - Si ejecutas
top -inuse_space, el culpable serásimulateRetention. Verás que la memoria ocupada es de aproximadamente 500 MB. Esto te indica un problema de retención de memoria, es decir, que la memoria está “viva” y no puede ser liberada. - Si usas
list simulateChurn, el comando te mostrará exactamente en qué línea se está produciendo la asignación constante, permitiéndote ver la líneamake([]byte, 1024).
El error frecuente
El error más común es intentar optimizar la latencia del sistema usando inuse_space.
Imagina que tienes un servicio que procesa 100,000 mensajes por segundo. Si tu código crea un nuevo objeto para cada mensaje, el inuse_space será muy bajo (porque los objetos mueren rápido), pero tu CPU estará saturado haciendo Stop-The-World para limpiar el desastre. Al ejecutar go tool pprof -inuse_space, verás que todo parece estar bien (poca memoria en uso), pero tu latencia es pésima. En este escenario, la métrica que te dice la verdad es alloc_space (o alloc_objects para ver cuántas veces el GC tiene que trabajar).
Para diagnosticar problemas de rendimiento por GC, busca siempre la agitación (alloc_space), no solo el consumo (inuse_space).
N° 164