TL;DR: El GC de Go está diseñado para minimizar pausas, no allocations.
Optimizarlo no significa tunearlo — significa darle menos trabajo.
Introduction
Hay una trampa muy común cuando los developers de Go empiezan a optimizar performance: van directo a ajustar GOGC o a buscar configuraciones del runtime. Están atacando el síntoma. El GC de Go tiene poco que tunear, y lo que sí podés controlar es cuánto trabajo le das.
Este artículo cubre cómo funciona el GC internamente, cómo medirlo, y los patrones concretos para reducir la presión de allocations — basado en el High Performance Go Workshop de Dave Cheney.
Cómo piensa el GC de Go
El garbage collector de Go solo actúa sobre el heap — la memoria compartida donde viven los objetos que necesitan sobrevivir más allá del stack de la función que los creó. El stack de cada goroutine se gestiona solo: cuando una función retorna, su frame desaparece sin que el GC intervenga. La asignación en stack es esencialmente gratis.
Al iniciar cada ciclo, el GC escanea todos los stacks activos buscando punteros que apuntan al heap. Esos son sus raíces — el punto de partida para construir el grafo de objetos vivos.
El algoritmo es concurrent mark-sweep:
- Mark: recorre el grafo desde las raíces y marca todo objeto alcanzable, mientras tu programa sigue corriendo.
- Sweep: libera la memoria de los objetos no marcados.
Imaginate un equipo de limpieza que entra a la oficina mientras vos seguís trabajando: primero anotan qué escritorios están ocupados, después retiran los vacíos. Para no descartar algo que se movió durante la limpieza, el runtime usa write barriers — interceptores que registran cada escritura de puntero en tu programa. Esto mantiene la consistencia, pero tiene un costo: los write barriers se activan en toda escritura de puntero, no solo durante el GC. Menos objetos en el heap significa menos punteros, menos write barriers, y menos overhead acumulado.
La decisión de diseño central de Go es priorizar pause times bajos sobre frecuencia de colección. El GC puede correr muchas veces si hay muchas allocations — eso es aceptable. Las pausas largas que congelen tu programa, no.
Cómo monitorear el GC
Optimizar sin medir es adivinar. Go te da tres herramientas complementarias para entender qué está haciendo el GC antes de tocar una sola línea de código.
GODEBUG=gctrace=1 es la más directa:
GODEBUG=gctrace=1 ./mi-programa
Cada ciclo de GC imprime una línea en stderr:
gc 1 @0.012s 2%: 0.015+1.2+0.050 ms clock, 4->4->2 MB, 5 MB goal, 8 P
El fragmento 4->4->2 MB es el más informativo: heap antes de la colección, heap alcanzable durante el mark, heap después del sweep. Si el número del medio siempre se mantiene cercano al primero, tenés muchos objetos vivos que el GC no puede liberar — el problema está en tu estructura de datos, no en la frecuencia de allocations.
Memory profiling con pprof te dice exactamente qué código está allocando:
go tool pprof -alloc_objects mem.prof
La métrica clave es alloc_objects — la cantidad total de objetos creados — más que inuse_bytes. Un objeto que nace y muere rápido no aparece en la memoria en uso, pero presiona el GC cada vez que se crea.
El execution tracer muestra las pausas del GC en una línea de tiempo real:
go test -trace=trace.out ./...
go tool trace trace.out
Esto abre una vista en el browser donde podés ver exactamente cuándo el GC interrumpió tu programa y cuánto duró cada pausa.
Las tres herramientas se complementan: gctrace dice cuánto trabaja el GC, pprof dice quién le da trabajo, el tracer dice cuándo interrumpe tu programa. La mayoría de los problemas de performance en Go no son algoritmos lentos — son allocation rates altos que fuerzan GC frecuente. Sin estos números, nunca sabés si tu optimización movió la aguja.
El costo oculto de strings y []byte
El lugar donde más allocations accidentales ocurren en Go es en la interacción entre string y []byte. Representan lo mismo — una secuencia de bytes — pero con contratos distintos: string es inmutable, []byte es mutable. Cada conversión entre uno y otro copia los datos subyacentes.
s := "hola mundo"
b := []byte(s) // copia — nueva allocation
s2 := string(b) // copia otra vez — otra allocation
En un hot path esto se acumula rápido. La regla práctica es simple: elegí una forma según dónde más se usa el dato y quedate en esa forma el mayor tiempo posible. Convertí solo en el borde.
La concatenación con + tiene el mismo problema:
s := "hola" + " " + "mundo" // dos allocations intermedias
Cada + crea un string nuevo. La solución es strings.Builder, que acumula internamente un []byte y crea el string final solo cuando lo pedís:
var b strings.Builder
b.Grow(64) // opcional: reservar capacidad si conocés el tamaño aproximado
b.WriteString("hola")
b.WriteString(" ")
b.WriteString("mundo")
s := b.String() // única allocation
Hay una excepción interesante: cuando usás []byte como clave en un map, el compilador evita la allocation en conversiones inline:
m := map[string]int{}
key := []byte("algo")
m[string(key)]++ // el compilador optimiza esto — no siempre alloca
Esta optimización solo se aplica en el contexto de lookup directo en map. En cualquier otro contexto, la conversión tiene su costo.
stringy[]byteson dos idiomas que dicen lo mismo. Cada traducción tiene un costo — hablá en un solo idioma el mayor tiempo posible.
Reutilizar memoria: pre-allocación y sync.Pool
Si la estrategia anterior es evitar allocations, esta es reutilizar memoria que ya tenés.
Pre-allocar slices cuando conocés el tamaño elimina las re-allocations internas de append. Cuando un slice se llena, Go duplica su capacidad y copia todo — eso puede pasar varias veces durante un loop:
// Ineficiente: múltiples re-allocations durante el loop
var items []int
for i := 0; i < 1000; i++ {
items = append(items, i)
}
// Eficiente: una sola allocation
items := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
items = append(items, i)
}
Pre-allocar también mejora la cache locality: todos los elementos quedan contiguos en memoria desde el principio. No necesitás saber el tamaño exacto — una buena estimación ya elimina la mayoría de las re-allocations.
sync.Pool es el patrón para objetos que se crean y descartan frecuentemente, como buffers. En lugar de crear uno nuevo cada vez, lo tomás del pool, lo usás, y lo devolvés:
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // siempre resetear antes de usar
defer bufPool.Put(buf)
// usás buf normalmente...
Tres reglas que no podés olvidar:
-
Siempre
Reset()antes de usar — el objeto puede tener estado del uso anterior. -
El GC puede vaciar el pool —
sync.Poolno es una caché persistente. No lo uses para guardar estado entre requests. - Solo vale cuando hay overhead de creación real — si el objeto es barato de crear, el pool agrega complejidad sin beneficio. Medí primero.
La librería estándar usa sync.Pool internamente en fmt, encoding/json, y otros paquetes de alto throughput. En servicios con alto volumen de requests, este patrón para buffers puede reducir las allocations por request a casi cero.
Key Takeaways
- El GC de Go solo gestiona el heap — el stack se limpia solo cuando retorna la función.
- El algoritmo es concurrent mark-sweep; prioriza pausas cortas sobre frecuencia de colección.
- Los write barriers añaden overhead a toda escritura de puntero — menos heap significa menos write barriers.
- Medí antes de optimizar:
gctracepara frecuencia,pprofpara fuentes, tracer para pausas. - Las conversiones entre
stringy[]bytesiempre copian — minimizá los cruces en el hot path. - Usá
strings.Builderen lugar de concatenación con+cuando armás strings dinámicamente. - Pre-allocá slices con
make([]T, 0, n)cuando conocés el tamaño aproximado. - Usá
sync.Poolpara objetos costosos de crear que se reutilizan frecuentemente — y siempreReset()antes de usar.
Further Reading
- High Performance Go Workshop — Dave Cheney
- Go GC: Prioritizing low latency and simplicity
sync.Pooldocumentationstrings.Builderdocumentation
¿Te resultó útil? Seguime en dev.to para más artículos sobre performance en Go y sistemas de alto throughput. Los comentarios y preguntas son bienvenidos — respondé abajo.
Top comments (0)