Rendimiento y memoria en Go: profiling, optimización y buenas prácticas

Capítulo 11

Tiempo estimado de lectura: 9 minutos

+ Ejercicio

Qué significa “rendimiento” en Go (y por qué medirlo)

En Go, el rendimiento suele estar dominado por tres factores: uso de CPU, presión de memoria (asignaciones y recolección de basura) y contención (bloqueos, espera en canales, syscalls, red). Optimizar sin medir suele empeorar el código o mover el problema a otro lugar. La práctica recomendada es: definir una métrica (latencia p95/p99, throughput, uso de CPU, RSS), capturar perfiles, proponer un cambio pequeño, volver a medir y comparar.

Profiling con pprof: CPU, heap, goroutines y bloqueo

Instrumentación básica en servicios

La forma más directa de perfilar un servicio es exponer endpoints de net/http/pprof en un puerto interno. Esto permite capturar perfiles en caliente sin detener el proceso.

import (  "net/http"  _ "net/http/pprof"  "log")func startPprof() {  go func() {    // Idealmente en localhost o red interna, con control de acceso.    log.Println(http.ListenAndServe("127.0.0.1:6060", nil))  }()}

Endpoints útiles: /debug/pprof/profile (CPU), /debug/pprof/heap (memoria), /debug/pprof/goroutine (stacks), /debug/pprof/block (bloqueos), /debug/pprof/mutex (contención de mutex). Para block y mutex necesitas habilitar muestreo.

import "runtime"func enableContentionProfiling() {  runtime.SetBlockProfileRate(10000) // 1 muestra cada ~10µs bloqueado (ajusta según overhead)  runtime.SetMutexProfileFraction(10) // 1 de cada 10 eventos de contención (ajusta) }

Capturar y analizar perfil de CPU (paso a paso)

  • 1) Captura 30s de CPU (ajusta duración para cubrir carga real):

    go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/profile?seconds=30
  • 2) En la UI, revisa: Top (funciones más costosas), Flame Graph (rutas calientes), Graph (relaciones).

    Continúa en nuestra aplicación.
    • Escuche el audio con la pantalla apagada.
    • Obtenga un certificado al finalizar.
    • ¡Más de 5000 cursos para que explores!
    O continúa leyendo más abajo...
    Download App

    Descargar la aplicación

  • 3) Interpreta “flat” vs “cum”: flat es tiempo en esa función; cum incluye llamadas descendientes. Un cum alto con flat bajo suele indicar que la función orquesta trabajo pesado en otras.

  • 4) Repite con el mismo escenario tras un cambio y compara (ver sección “medir antes/después”).

Heap profile: asignaciones, retención y GC

El heap profile te ayuda a responder: ¿qué asigna más?, ¿qué objetos se retienen?, ¿estoy creando basura innecesaria? Importante: el heap puede verse de dos maneras: en uso (objetos vivos) y asignado (total asignado a lo largo del tiempo). Para encontrar “churn” (mucha basura), mira asignaciones; para fugas/retención, mira en uso.

  • Captura heap:

    go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/heap
  • Consejo: en la UI, cambia entre inuse_space, inuse_objects, alloc_space, alloc_objects para ver perspectivas distintas.

Goroutines: detectar acumulación y puntos de espera

El perfil de goroutines muestra stacks de todas las goroutines. Es clave para detectar: goroutines que se quedan esperando para siempre, fugas por canales no drenados, o explosión de concurrencia.

go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/goroutine

Busca patrones repetidos (muchas goroutines con el mismo stack) y estados como chan receive, select, sync.Mutex.Lock, IO wait.

Bloqueo y contención: block y mutex

Si la CPU no está alta pero la latencia sí, suele haber espera: locks, canales, syscalls o red. Los perfiles de bloqueo ayudan a cuantificar dónde se pierde tiempo esperando.

go tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/blockgo tool pprof -http=:0 http://127.0.0.1:6060/debug/pprof/mutex

Interpreta con cuidado: habilitar estos perfiles tiene overhead. Úsalos en entornos de staging o ventanas controladas en producción.

Asignaciones, escapes y cómo reducir presión de memoria

Asignaciones: el costo oculto

Cada asignación en heap incrementa trabajo del GC. En cargas altas, reducir asignaciones suele mejorar latencia p99 más que micro-optimizaciones de CPU. Señales típicas: heap con alloc_space alto, GC frecuente, y perfiles con muchas llamadas a make, new, conversiones a string o concatenaciones.

Escape analysis: cuándo algo se va al heap

Go decide si una variable vive en stack o heap. Si “escapa” (por ejemplo, se devuelve un puntero, se guarda en una interfaz, o el compilador no puede probar el alcance), se asigna en heap. Puedes inspeccionar escapes con flags del compilador.

go build -gcflags="all=-m=2" ./...

Busca mensajes como escapes to heap. No se trata de eliminar todos los escapes, sino de entender los que ocurren en rutas calientes.

Reuso de buffers: menos basura, más throughput

Patrón común: construir respuestas o payloads con buffers reutilizables. Dos herramientas típicas: bytes.Buffer (con Grow) y sync.Pool para reusar objetos temporales bajo carga.

var bufPool = sync.Pool{  New: func() any {    b := make([]byte, 0, 32*1024)    return &b  },}func withBuffer(fn func([]byte) []byte) []byte {  bptr := bufPool.Get().(*[]byte)  b := (*bptr)[:0]  out := fn(b)  // Importante: no guardar referencias a b tras devolverlo al pool.  *bptr = b[:0]  bufPool.Put(bptr)  return out}
  • Cuándo usar sync.Pool: objetos temporales, alta tasa de asignación, vida corta, y cuando el GC está siendo un problema.
  • Cuándo evitarlo: si complica el código, si hay riesgo de retener buffers enormes, o si el beneficio no se demuestra con perfiles.

Preasignación de slices y mapas

Si conoces tamaños aproximados, preasigna capacidad para evitar realocaciones y copias.

// Slice: evita crecimientos repetidosres := make([]Item, 0, n)// Map: reduce rehashingm := make(map[string]int, n)

Costo de conversiones: []bytestring y concatenaciones

Convertir entre []byte y string normalmente implica copiar datos (asignación + copia). En rutas calientes (parsers, middlewares, logging), esto puede dominar el perfil de heap.

  • Evita concatenar strings en bucles; usa strings.Builder y Grow si puedes estimar tamaño.
var b strings.Builderb.Grow(1024)for _, s := range parts {  b.WriteString(s)  b.WriteByte(',')}out := b.String()
  • Evita convertir repetidamente: si una API interna puede operar en []byte o en string, elige una representación y manténla.

Nota: existen técnicas “zero-copy” con unsafe, pero suelen ser frágiles y no recomendables en código general; prioriza claridad y mide si realmente lo necesitas.

Patrones de optimización sin sacrificar legibilidad

Optimiza primero lo que el perfil señala

Regla práctica: si una función no aparece en el top del perfil, rara vez vale la pena micro-optimizarla. En cambio, enfócate en: reducir asignaciones en rutas calientes, evitar trabajo repetido, y disminuir contención.

Evita trabajo repetido: cache local y precomputación

  • Compilar regex una sola vez (global o inyectado) en lugar de por request.
  • Precalcular estructuras derivadas (tablas, índices) si el input cambia poco.
  • Evitar formateos costosos en logs en nivel deshabilitado (usa logging estructurado con evaluación perezosa si aplica).

Reduce contención: sharding y minimizar secciones críticas

Si un mutex aparece en el perfil, prueba: reducir el trabajo dentro del lock, usar estructuras por shard (por ejemplo, N mapas con N locks), o usar canales/buffers para serializar solo lo necesario. No cambies a estructuras “lock-free” sin evidencia: suelen complicar y no siempre mejoran.

Batching y amortización

En cargas altas, agrupar operaciones reduce overhead: escribir en lote, procesar eventos en batches, o usar buffers para I/O. Mide la latencia: batching puede aumentar p99 si el tamaño/intervalo no está bien ajustado.

Cómo medir antes/después (benchmarks y comparación de perfiles)

Microbenchmarks con go test -bench

Para cambios locales (funciones, codecs, parsers), usa benchmarks y reporta asignaciones.

go test ./... -bench=BenchmarkMiFuncion -benchmem -run=^$

Interpreta: ns/op (tiempo), B/op (bytes asignados), allocs/op (número de asignaciones). Un objetivo común es bajar allocs/op en rutas calientes.

Comparar perfiles con pprof (diff)

Guarda perfiles “antes” y “después” bajo el mismo escenario de carga.

# CPU antescurl -o cpu_before.pb.gz "http://127.0.0.1:6060/debug/pprof/profile?seconds=30"# CPU despuéscurl -o cpu_after.pb.gz "http://127.0.0.1:6060/debug/pprof/profile?seconds=30"# Comparación (pprof permite base)go tool pprof -http=:0 -base cpu_before.pb.gz cpu_after.pb.gz

Haz lo mismo con heap si el cambio apunta a asignaciones. Asegúrate de que el tráfico, configuración y versión de Go sean comparables.

Medición a nivel servicio: latencia y recursos

Para cambios en un servicio, mide con un generador de carga reproducible y observa: latencia p50/p95/p99, throughput, CPU, RSS, GC (pausas y frecuencia), y número de goroutines. Un cambio “bueno” suele mejorar p99 o estabilizar memoria bajo carga sostenida.

Recomendaciones para servicios de alta carga

Límites de concurrencia (no todo debe ser una goroutine)

Crear una goroutine por unidad de trabajo puede saturar CPU, memoria y el scheduler si el input crece. Usa límites explícitos: semáforos (canal con buffer) o pools de workers.

// Semáforo para limitar concurrencia a Nvar sem = make(chan struct{}, N)func doWork(item Item) {  sem <- struct{}{}  go func() {    defer func() { <-sem }()    process(item)  }()}

En endpoints, considera colas internas con tamaño máximo; si se llenan, aplica backpressure (rechazar o degradar).

Timeouts y cancelación

En alta carga, los timeouts evitan acumulación de trabajo inútil. Aplica timeouts en llamadas a dependencias (DB, HTTP) y propaga cancelación. Asegúrate de que goroutines internas respeten el contexto para no fugar trabajo.

Backpressure: proteger el sistema bajo presión

  • Colas acotadas: canales con buffer limitado para desacoplar picos sin crecer sin límite.
  • Rechazo temprano: si el sistema está saturado, responde con error controlado (por ejemplo, 429/503) en lugar de degradar todo.
  • Degradación: desactivar features costosas (cálculos extra, enriquecimiento) cuando se supera un umbral.

Uso prudente de concurrencia

  • Evita fan-out masivo (lanzar miles de goroutines) sin límite; prefiere pools o semáforos.
  • Evita locks globales en rutas calientes; usa sharding o reduce la sección crítica.
  • Observa goroutines: un crecimiento sostenido suele indicar bloqueo, fugas o falta de backpressure.

Checklist práctico para una sesión de optimización

ObjetivoQué capturarQué buscar
CPU altaCPU pprofTop funciones, flame graph, trabajo repetido
Memoria/GC altoHeap (alloc/inuse)allocs/op, conversiones, buffers, retención
Latencia alta con CPU moderadablock/mutex + goroutinecontención, esperas en canales, IO wait
Degradación bajo cargamétricas + goroutinescolas sin límite, falta de timeouts, fan-out

Ahora responde el ejercicio sobre el contenido:

Al perfilar un servicio en Go, la latencia es alta pero el uso de CPU no. ¿Qué enfoque es el más adecuado para investigar el problema?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Si la CPU no está alta pero la latencia sí, suele haber tiempo perdido esperando (contención y bloqueos). Los perfiles block y mutex, junto con el perfil de goroutines, ayudan a localizar esas esperas y cuantificarlas.

Siguiente capítulo

Servicios escalables en Go: configuración, observabilidad y despliegue

Arrow Right Icon
Portada de libro electrónico gratuitaGo desde Cero: Programación Moderna, Rápida y Escalable
92%

Go desde Cero: Programación Moderna, Rápida y Escalable

Nuevo curso

12 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.