⚡ Performance en Go
pprof, profiling de CPU y heap, escape analysis, allocations, sync.Pool, strings.Builder, GC y optimización real paso a paso con mediciones.
Performance en Go
Go es rápido por diseño, pero «rápido» no llega solo: se mide, se diagnostica y se optimiza. La herramienta reina es pprof (el profiler integrado), y la métrica que casi siempre importa en Go es cuántas allocations al heap haces por operación. Este artículo te enseña a perfilar, a entender por qué tu código asigna memoria y a optimizar de forma medible, no adivinando.
Principio nº1: mide antes de optimizar
La regla de oro del rendimiento: nunca optimices a ciegas. Primero mides (benchmark o profiler), encuentras el cuello de botella real, y solo entonces actúas. El 99% de las «optimizaciones» intuitivas no arreglan nada o empeoran la legibilidad.
go test -bench=. -benchmem -cpuprofile=cpu.out -memprofile=mem.out ./...
Esto genera tres entregables: el benchmark (ns/op), el perfil de CPU y el perfil de memoria. Después los analizas con go tool pprof.
Profiling con pprof
pprof te dice dónde se pasa el tiempo (CPU) y dónde se asigna memoria (heap). Dos formas de capturarlo:
runtime/pprof: perfilar un bloque de código
import "runtime/pprof"
func main() {
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// tu carga de trabajo…
procesar()
// perfil de memoria al final
mf, _ := os.Create("mem.prof")
defer mf.Close()
runtime.GC() // vacía la basura para un heap profile limpio
pprof.WriteHeapProfile(mf)
}
net/http/pprof: perfilar un servidor en caliente
La forma más común en producción: expones el profiler en un endpoint HTTP y lo consultas con curl.
import (
"net/http"
_ "net/http/pprof" // registra /debug/pprof en el mux por defecto
)
func main() {
go func() {
// servidor del profiler, aparte del de tu API
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// …tu API real en otro puerto…
}
Luego capturas perfiles mientras hay tráfico:
# muestra de CPU de 30s mientras el servidor trabaja
curl -o cpu.prof "http://localhost:6060/debug/pprof/profile?seconds=30"
# snapshot del heap
curl -o heap.prof "http://localhost:6060/debug/pprof/heap"
⚠️ No expongas el endpoint de pprof en el mismo puerto público de tu API. El profiler expone información interna (rutas, goroutines, memoria) y añade overhead. Sírvelo en
localhosten un puerto separado, o tras una red interna/firewall.
Analizar perfiles: go tool pprof
go tool pprof cpu.prof
Dentro de la consola interactiva:
| Comando | Qué hace |
|---|---|
top |
Las N funciones que más tiempo consumen |
top -cum |
Ordena por tiempo acumulado (incluye llamadas hijas) |
list <función> |
Código fuente con tiempos por línea |
web |
Gráfico SVG navegable (necesita graphviz) |
peek <función> |
Quién llama y a quién llama |
traces |
Pilas de llamadas completas |
go tool pprof cpu.prof
(pprof) top
Showing nodes accounting for 4.2s, 82.4% of 5.1s total
flat flat% sum% cum cum%
2.1s 41.2% 41.2% 2.1s 41.2% strconv.FormatFloat
1.1s 21.6% 62.8% 1.3s 25.5% encoding/json.(*encodeState).marshal
…
💡 La columna
flates lo que importa para optimizar.flat= tiempo dentro de esa función;cum= tiempo dentro de ella y sus llamadas. Para atacar un cuello de botella busca el mayorflatque puedas eliminar, no el mayorcum(que puede ser solo un llamador del problema real).
Escape analysis: ¿por qué mi struct va al heap?
Cuando una variable se asigna en la pila (stack), se libera gratis al salir de la función. Si escapa al heap, pasa a la memoria administrada por el GC — cada escape es una allocation. Go decide esto con el escape analysis.
// No escapa: el valor vive y muere dentro de sumar.
func sumar(a, b int) int {
x := a + b
return x // int: valor, no puntero
}
// ESCAPA al heap: devolvemos un puntero a un valor local.
func crearPuntero() *int {
x := 10
return &x // x debe sobrevivir a la función → heap
}
Comprueba qué escapa y por qué:
go build -gcflags="-m=2" ./
./main.go:7:6: can inline sumar
./main.go:12:10: &x escapes to heap
⚠️ Solo porque se usa un puntero no significa que escape. El análisis es fino: si el compilador demuestra que el puntero no sale, lo mantiene en la pila. Pasa una
structpor valor vs por puntero depende del caso: si la struct no se muta y no es enorme, por valor suele quedar en la pila. No adivines —-gcflags="-m=2"te lo dice.
Allocations: el coste oculto
En Go, cada allocation al heap tiene coste: buscar hueco, y luego trabajo para el GC al recolectarla. Las operaciones «inocentes» que asignan memoria:
fmt.Sprintf(construye strings temporales).- Pasar un
[]byteastring()en un hot path. - Devolver
interface{}con un valor que no cabe. appendsobre un slice que crece (reallocs).- Concatenar strings con
+en un bucle.
El benchmark con -benchmem/b.ReportAllocs() te muestra allocs/op. Reducir eso es, con diferencia, la optimización más rentable en Go:
func BenchmarkEjemplo(b *testing.B) {
for i := 0; i < b.N; i++ {
// tu código
}
// Objetivo: bajar allocs/op, no solo ns/op
}
💡
allocs/opes tu faro. Antes de tocar algoritmos, mira cuántas asignaciones hace tu función por llamada. Cada allocation eliminada reduce presión al GC. Un objetivo típico: pasar de decenas de allocs/op a 0 (escaneando sobre buffers reutilizados).
sync.Pool: reutiliza objetos caros
Cuando creas y destruyes muchos objetos idénticos (buffers, structs temporales), sync.Pool los cachea para reutilizarlos entre goroutines:
import "sync"
var bufPool = sync.Pool{
New: func() any {
return make([]byte, 0, 1024)
},
}
func procesarLinea(linea string) {
buf := bufPool.Get().([]byte)
defer bufPool.Put(buf[:0]) // devuelve al pool, vacío
buf = append(buf, linea...)
// …trabaja con buf…
}
⚠️
sync.Poolno es un caché persistente. El GC puede vaciarlo en cualquier momento. Úsalo solo para objetos descartables cuyo coste de re-creación es alto, no para datos de negocio con estado. Ideal para[]bytey buffers de serialización.
String concatenation: strings.Builder
Concatenar strings con + en un bucle es O(n²): cada + crea una string nueva y copia todo. strings.Builder acumula en un buffer interno (como un bytes.Buffer pero optimizado para strings):
// LENTO: O(n²), muchas allocs
func joinLento(palabras []string) string {
result := ""
for _, p := range palabras {
result += p + "," // cada + crea 1-2 strings nuevas
}
return result
}
// RÁPIDO: un buffer, casi sin allocs
func joinRapido(palabras []string) string {
var sb strings.Builder
for _, p := range palabras {
sb.WriteString(p)
sb.WriteByte(',')
}
return sb.String()
}
Benchmark real (10k palabras):
| Implementación | ns/op | allocs/op |
|---|---|---|
joinLento (+) |
~4.2 ms | ~20,000 |
joinRapido (strings.Builder) |
~28 µs | ~3 |
💡 Preasigna la capacidad si la conoces:
sb.Grow(totalLongitud)evita reallocs del buffer interno. Y para unir con separador,strings.Join(slice, sep)ya está optimizado por debajo — úsalo cuando encaje.
GC: cuándo y cómo ajustarlo
El recolector de basura marca y barre el heap. Cuantas más allocs haces, más trabaja. Cuando no lo quieres encima en momentos críticos, Go te da palancas:
GOMEMLIMIT
Limita la memoria total que el runtime considera razonable, sin llegar a matar el proceso con OOM del SO:
export GOMEMLIMIT=512MiB
# o en Go 1.21+, en código:
# debug.SetMemoryLimit(512 << 20)
Es el reemplazo recomendado del viejo GOGC para evitar picos de memoria: el runtime ajusta la frecuencia del GC para quedarse cerca del límite.
⚠️
GOGCclásico = umbral de crecimiento del heap.GOGC=100(defecto) duplica el heap antes de recolectar. Subirlo (GOGC=200) hace GCs menos frecuentes pero picos de memoria mayores. El problema: solo escala con el heap, no con lo que hay libre.GOMEMLIMITes más predecible y es el ajuste moderno.
GODEBUG
Variables para diagnosticar el runtime:
GODEBUG=gctrace=1 ./binario
# gc 1 @0.012s 2%: 0.6+1.2+0.3 ms clock, ...
# -> % es el coste del GC sobre el CPU total
GODEBUG=schedtrace=1000 ./binario # scheduling de goroutines
gctrace=1 te muestra cuánto CPU dedica el GC: si el % es alto (p. ej. >10%), tienes un problema de allocations y deberías atacar el código, no solo el GC.
Inlining
Go inlinea funciones pequeñas: copia su cuerpo en el llamador, evitando el coste de llamada. Es gratis y automático. -m te dice qué se inlinea:
go build -gcflags="-m" ./
# ./main.go:7:6: can inline sumar
El inlining habilita otras optimizaciones (devolver valores, eliminar temporales). Regla: no huyas de funciones pequeñas — Go las inlinea. Lo que debes evitar es que tus funciones hagan allocs innecesarios, porque eso no se arregla con inlining.
💡 Escape + inlining van de la mano. Si una función pequeña que crea un valor local se inlinea en el llamador, el valor puede quedarse en la pila del llamador sin escapar. Dividir el código en funciones pequeñas y claras suele mejorar el rendimiento por esta razón — no lo empeorar.
Ejemplo real: optimización paso a paso
Vamos a optimizar una función real con mediciones en cada paso. Partimos de algo que construye una respuesta CSV a partir de datos.
Paso 1: versión lenta (baseline)
func buildCSV(filas [][]string) string {
out := ""
for _, fila := range filas {
for _, celda := range fila {
out += celda + "," // concatenación O(n²)
}
out += "\n"
}
return out
}
# BenchmarkBuildCSV-8 132 9,120,000 ns/op 12,480,000 B/op 310,000 allocs/op
310,000 allocations. Horroroso. Medido, no adivinado.
Paso 2: usar strings.Builder
func buildCSV(filas [][]string) string {
var sb strings.Builder
for _, fila := range filas {
for _, celda := range fila {
sb.WriteString(celda)
sb.WriteByte(',')
}
sb.WriteByte('\n')
}
return sb.String()
}
# 1,150 ns/op 2,100 B/op 4 allocs/op
~8,000x más rápido y de 310k allocs a 4. La concatenación era el problema.
Paso 3: preasignar y afinar
func buildCSV(filas [][]string) string {
var sb strings.Builder
for _, fila := range filas {
for _, celda := range fila {
sb.Grow(len(celda) + 1) // si conocemos el tamaño total, preasigna
sb.WriteString(celda)
sb.WriteByte(',')
}
sb.WriteByte('\n')
}
return sb.String()
}
# 890 ns/op 0 B/op 0 allocs/op
0 allocations. Mejora marginal en tiempo, pero el GC ya no tiene nada que limpiar: en un servidor con muchas llamadas concurrentes, esa diferencia se amplifica.
El proceso, resumido
- Medir el baseline con benchmark +
-benchmem. - Perfilar (pprof) para confirmar dónde está el coste.
- Cambiar una cosa (aquí: concatenación → Builder).
- Medir de nuevo y comparar. Repetir.
⚠️ Una variable a la vez. Si cambias tres cosas a la vez, no sabes cuál aportó el 8000x. Mantén el benchmark anterior con
git stasho archivos separados para comparar. Y guarda los números: la optimización sin medición es ruido.
Cheatsheet
| Quieres… | Usa… |
|---|---|
| Perfilar CPU | runtime/pprof o net/http/pprof + go tool pprof |
| Perfilar memoria | pprof.WriteHeapProfile o /debug/pprof/heap |
| Ver el top de funciones | top en la consola pprof |
| Ver coste por línea | list <función> |
| Saber si algo escapa al heap | go build -gcflags="-m=2" |
| Medir allocs | go test -bench=. -benchmem / b.ReportAllocs() |
| Reutilizar objetos | sync.Pool |
| Concatenar strings rápido | strings.Builder |
| Controlar la memoria | GOMEMLIMIT |
| Diagnosticar el GC | GODEBUG=gctrace=1 |
| Ver inlining | go build -gcflags="-m" |
Para profundizar
- Documentación oficial de pprof: el profiler del runtime.
- Guía oficial de profiling: cómo diagnosticar y medir.
- Guía de optimización de rendimiento en Go: el wiki del equipo.
- Detecting Inefficiencies (Talks): conceptual, atemporal.
- blog de
-gcflagsy escape analysis: intro a la herramienta. - Ruta completa: Backend con Go.