🔎 Buscar

⚡ 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.

Wiki / Apuntes📖 Contenido

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 localhost en 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 flat es 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 mayor flat que puedas eliminar, no el mayor cum (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 struct por 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 []byte a string() en un hot path.
  • Devolver interface{} con un valor que no cabe.
  • append sobre 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/op es 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.Pool no 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 []byte y 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.

⚠️ GOGC clá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. GOMEMLIMIT es 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

  1. Medir el baseline con benchmark + -benchmem.
  2. Perfilar (pprof) para confirmar dónde está el coste.
  3. Cambiar una cosa (aquí: concatenación → Builder).
  4. 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 stash o 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

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.