Go decide por ti si un valor vive en el stack o en el heap. Mira cómo el escape analysis toma esa decisión, cómo verla con go build -gcflags=-m, cómo contar asignaciones y qué hace después el recolector de basura.
En Go nunca escribes «pon esto en el heap». Escribes código normal, y el compilador decide dónde vive cada valor. Casi siempre no te tiene que importar. Pero cuando un profile te dice que un bucle crítico asigna memoria, necesitas saber cómo se toma esa decisión y cómo verla.
Este post cubre el stack, el heap, el escape analysis, cómo contar asignaciones y el recolector de basura que limpia el heap. Cada programa de abajo se ejecutó en Go 1.26, y su salida está copiada de esa ejecución. Cuando algo lo afirma el compilador, también está copiada la salida del propio compilador.
Cada llamada recibe un frame en el stack
Cuando se llama a una función de Go, recibe un frame: un bloque de memoria con sus variables locales y algunos datos de control. Cuando la función retorna, su frame se saca del stack y la siguiente llamada reutiliza esa memoria. Cada goroutine tiene su propio stack de frames.
El stack de una goroutine empieza pequeño y crece cuando lo necesita. Puedes preguntarle al runtime con qué tamaño empiezan los stacks nuevos, y después hacer una recursión mucho más profunda de lo que ese tamaño permite:
package main
import (
"fmt"
"runtime/metrics"
)
func depth(n int) int {
if n == 0 {
return 0
}
return 1 + depth(n-1)
}
func main() {
s := []metrics.Sample{{Name: "/gc/stack/starting-size:bytes"}}
metrics.Read(s)
fmt.Println("new goroutine stacks start at", s[0].Value.Uint64(), "bytes")
fmt.Println(depth(1_000_000))
}
Imprime:
new goroutine stacks start at 2048 bytes
1000000
Un millón de llamadas anidadas no caben en 2.048 bytes, así que el stack creció. El código fuente del runtime describe cómo: cuando una función necesita más stack del que queda, el runtime reserva un stack más grande, copia ahí el viejo y sigue. También puede volver a achicar los stacks durante la recolección de basura. El 2.048 es lo que reporta Linux en esta ejecución. Además, el runtime ajusta el tamaño inicial con el tiempo, según cuánto stack han estado usando las goroutines, así que un programa que lleva mucho tiempo corriendo puede reportar un número mayor.
El crecimiento tiene un techo. La documentación de runtime/debug.SetMaxStack dice que el stack de una sola goroutine puede crecer hasta 1 GB en sistemas de 64 bits. Una recursión infinita llega a ese límite y el programa cae con un stack overflow.
El heap guarda lo que sobrevive a la llamada
Algunos valores tienen que seguir vivos después de que retorna la función que los creó. Un frame desaparece en cuanto su función retorna, así que esos valores no pueden vivir ahí. Van al heap, una zona de memoria compartida que no está atada a ninguna llamada. El recolector de basura libera la memoria del heap cuando ya nada la referencia.
Explicado como si tuvieras diez años
El stack es una pila de libretas sobre tu escritorio. Cada vez que empiezas una tarea, pones una libreta nueva encima y escribes en ella. Cuando terminas la tarea, tiras esa libreta. Una tarea dentro de otra tarea solo agrega otra libreta. Es rápido, y limpiar no cuesta nada: siempre tiras solo la libreta de arriba.
El heap es un depósito compartido al fondo del pasillo. Si haces algo que otra persona todavía necesita cuando tú ya terminaste, no lo puedes dejar en tu libreta, porque esa va a la basura. Así que lo pones en una caja en el depósito y le das a esa persona una etiqueta que dice en qué estante está.
Cada tanto pasa alguien de limpieza por el depósito. Cualquier caja a la que no apunte la etiqueta de nadie se tira.
La versión precisa
Un frame del stack se crea cuando se llama a una función y se descarta cuando retorna. Asignar memoria en un frame casi no cuesta nada, y después nadie tiene que llevar la cuenta.
Una asignación en el heap le pide memoria al runtime. Esa memoria sigue siendo válida mientras algún puntero pueda llegar a ella. Más tarde, el recolector de basura (GC) encuentra los objetos del heap a los que no llega ningún puntero y reutiliza su memoria. Así que los valores del heap cuestan dos veces: una al asignarlos, y otra como trabajo para el GC.
Dónde falla la analogía: tú no decides qué valores van al depósito. Lo decide el compilador cuando construye tu programa, y la siguiente sección muestra cómo. Y la persona de limpieza no lee las etiquetas una por una a medida que dejas cajas. Recorre todo lo que se puede alcanzar desde las libretas que siguen sobre los escritorios, y la parte sobre el GC, más abajo, explica cómo.
Tú no eliges: lo decide el escape analysis
Go no tiene ninguna regla que diga que new o & significan «heap». La especificación de Go ni siquiera usa las palabras stack o heap. El FAQ de Go deja clara la promesa: cada variable existe mientras haya referencias a ella. Cómo cumplir esa promesa es asunto del compilador.
El compilador la cumple con escape analysis (análisis de escape). Para cada valor, se pregunta si se puede llegar a ese valor después de que su función retorna. Si no puede demostrar que la respuesta es no, el valor escapa, y el compilador lo pone en el heap. Si no, es libre de dejarlo en el frame.
Por eso en Go es seguro devolver un puntero a una variable local. En C, el mismo código te devuelve la dirección de un frame del stack que ya no existe.
package main
import "fmt"
type point struct{ x, y int }
func sum() int {
p := point{1, 2}
return p.x + p.y
}
func newPoint() *point {
p := point{3, 4}
return &p
}
func five() int {
n := new(int)
*n = 5
return *n
}
func main() {
fmt.Println(sum(), newPoint().y, five())
}
Imprime:
3 4 5
Para ver las decisiones del compilador, guarda eso como main.go en un módulo y compílalo con -gcflags=-m. El -l extra desactiva el inlining, así que cada función se analiza exactamente como está escrita:
$ go build -gcflags='-m -l' .
# example.com/escape
./main.go:13:2: moved to heap: p
./main.go:18:10: new(int) does not escape
./main.go:24:13: ... argument does not escape
./main.go:24:17: sum() escapes to heap
./main.go:24:31: newPoint().y escapes to heap
./main.go:24:39: five() escapes to heap
La línea 13 es p en newPoint. Su dirección se devuelve, así que tiene que sobrevivir a la llamada, y el compilador movió la variable al heap («moved to heap»). La línea 18 es el new(int) de five: «does not escape», no escapa, porque el puntero nunca sale de la función. new no obligó a asignar en el heap. El p de sum ni siquiera aparece, porque nada toma nunca su dirección.
Las últimas cuatro líneas son sobre la llamada a fmt.Println. Se explican más abajo, en la sección sobre interfaces.
Contar asignaciones con testing.AllocsPerRun
La salida de -m te dice qué decidió el compilador. Para comprobar qué pasa de verdad cuando el código se ejecuta, cuenta las asignaciones. testing.AllocsPerRun llama a una función muchas veces y devuelve el número promedio de asignaciones en el heap por llamada. Puedes importar testing desde un package main normal:
package main
import (
"fmt"
"testing"
)
type point struct{ x, y int }
func newPoint(x, y int) *point {
return &point{x, y}
}
var saved *point
func main() {
dropped := testing.AllocsPerRun(1000, func() {
p := newPoint(3, 4)
_ = p.x + p.y
})
kept := testing.AllocsPerRun(1000, func() {
saved = newPoint(3, 4)
})
fmt.Println("used, then dropped:", dropped)
fmt.Println("kept in a global: ", kept)
}
Imprime:
used, then dropped: 0
kept in a global: 1
En código simple como este, la cuenta es la misma en cada ejecución, a diferencia de una medición de tiempo.
La misma función asignó memoria una vez en un lugar y ninguna en el otro. Compílalo con -m a secas, con el inlining activado, y busca point:
$ go build -gcflags=-m . 2>&1 | grep point
./main.go:11:9: &point{...} escapes to heap
./main.go:18:16: &point{...} does not escape
./main.go:22:19: &point{...} escapes to heap
La línea 11 es newPoint por sí sola, donde el puntero se devuelve y escapa. Pero newPoint es pequeña, así que el compilador le hizo inlining: copió el cuerpo de la función dentro de cada llamador y volvió a correr el escape analysis ahí. En la línea 18, el llamador solo lee p y lo descarta, así que el punto se queda en el stack. En la línea 22, el llamador lo guarda en una variable global, así que escapa.
Que algo escape depende tanto del código que lo rodea como de la función misma. Por eso mides en lugar de adivinar.
Cuatro motivos comunes por los que un valor escapa
La mayoría de los escapes que vas a encontrar vienen de unos pocos patrones. Devolver un puntero es el primero, y ya lo viste. Estos son los otros tres.
Guardar un valor en una interfaz
fmt.Println recibe sus argumentos como ...any. Poner un valor en una interfaz puede hacer que escape, porque la interfaz quizá necesite guardar un puntero a una copia del valor, y el compilador normalmente no puede ver qué hace con él la función llamada.
package main
import (
"fmt"
"io"
"testing"
)
func add(total *int, n int) {
*total += n
}
func show(n int) {
fmt.Fprintln(io.Discard, n)
}
func main() {
n, total := 1000, 0
added := testing.AllocsPerRun(1000, func() {
n++
add(&total, n)
})
shown := testing.AllocsPerRun(1000, func() {
n++
show(n)
})
small := testing.AllocsPerRun(1000, func() {
n++
show(n % 200)
})
fmt.Println("add n to a total:", added)
fmt.Println("print n: ", shown)
fmt.Println("print n % 200: ", small)
}
Imprime:
add n to a total: 0
print n: 1
print n % 200: 0
El compilador está de acuerdo con las dos primeras líneas:
$ go build -gcflags='-m -l' . 2>&1 | head -4
# example.com/boxing
./main.go:9:10: total does not escape
./main.go:14:14: ... argument does not escape
./main.go:14:27: n escapes to heap
add recibe un puntero pero nunca lo guarda, así que total no escapa. En show, n escapa hacia la interfaz, e imprimir cuesta una asignación por llamada.
La tercera línea fue la sorpresa. show(n % 200) pasa por el mismo código, y aun así no asigna memoria. El runtime tiene una tabla ya armada con los enteros del 0 al 255 (staticuint64s en runtime/iface.go), y una interfaz que guarda uno de ellos apunta a esa tabla en lugar de asignar memoria. Así que «escapes to heap» en la salida de -m significa que el compilador no pudo descartar una asignación en el heap. No promete que ocurra cada vez.
Un closure que captura una variable
Un closure que usa una variable de su función contenedora comparte esa variable, como mostró la parte sobre funciones. Si el closure sobrevive a la llamada, la variable también tiene que sobrevivir:
package main
import "fmt"
func makeCounter() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
next := makeCounter()
fmt.Println(next(), next(), next())
next = nil
fmt.Println(next == nil)
}
Imprime:
1 2 3
true
$ go build -gcflags='-m -l' .
# example.com/counter
./main.go:6:2: moved to heap: n
./main.go:7:9: func literal escapes to heap
./main.go:15:13: ... argument does not escape
./main.go:15:18: next() escapes to heap
./main.go:15:26: next() escapes to heap
./main.go:15:34: next() escapes to heap
./main.go:17:13: ... argument does not escape
./main.go:17:19: next == nil escapes to heap
n se mueve al heap, y también el propio valor función (el «func literal» de la línea 7). El resto son argumentos de fmt.Println que otra vez van a interfaces. Así se ve mientras el programa se ejecuta:
makeCounter del programa de arriba. Su frame guarda n, pero la función que devuelve usa n, así que n vive en el heap junto a esa función. El frame sale del stack, y el next de main apunta al heap. Cuando next se pone en nil, nada llega a la función ni a n, y el recolector de basura los libera.
Estos son los mismos pasos en palabras, por si la animación no se reproduce:
mainllama amakeCounter. Un frame nuevo va al stack, y ahí es donde esperarías que vivieran = 0.- La función que devuelve
makeCounterusan, así quentiene que sobrevivir al frame. Vive en el heap, junto al valor función. makeCounterretorna y su frame sale del stack.nextenmainapunta a la función en el heap, y la función apunta an.- Cada una de las tres llamadas a
next()suma uno a ese mismon, que ahora vale 3. next = nil. Ya nada en el stack apunta a la función ni an.- La próxima vez que corre el GC, no puede alcanzarlos, así que libera su memoria.
El dibujo muestra n moviéndose, pero en tiempo de ejecución no se mueve nada. El compilador tomó la decisión cuando construyó el programa, así que makeCounter crea n en el heap desde su primera línea. «Moved to heap» es la forma del compilador de decir «esta variable local no tiene lugar en el frame».
El flag -l también importa aquí. Con el inlining activado, makeCounter se expande dentro de main, y el compilador reporta esa copia del closure como que no escapa. El análisis de la animación es makeCounter tal como está escrita.
Un slice demasiado grande, o de tamaño desconocido
El array subyacente de un slice puede quedar en el frame cuando el compilador conoce su tamaño y el tamaño es moderado. Qué tan moderado es un ajuste del compilador, no una regla del lenguaje:
package main
import (
"fmt"
"testing"
)
func fixed8192() int {
buf := make([]int, 8192)
return len(buf)
}
func fixed8193() int {
buf := make([]int, 8193)
return len(buf)
}
func sized(n int) int {
buf := make([]int, n)
return len(buf)
}
func main() {
fmt.Println(testing.AllocsPerRun(100, func() { fixed8192() }))
fmt.Println(testing.AllocsPerRun(100, func() { fixed8193() }))
for _, n := range []int{4, 5} {
fmt.Println(testing.AllocsPerRun(100, func() { sized(n) }))
}
}
Imprime:
0
1
0
1
$ go build -gcflags='-m -l' . 2>&1 | grep make
./main.go:9:13: make([]int, 8192) does not escape
./main.go:14:13: make([]int, 8193) escapes to heap
./main.go:19:13: make([]int, n) does not escape
8.192 ints son 64 KiB, que es justo el límite para variables implícitas en el stack en el código fuente del compilador (MaxImplicitStackVarSize en cmd/compile/internal/ir/cfg.go). Un int más y el array va al heap, aunque nunca salga de la función.
make([]int, n) fue la segunda sorpresa. El compilador dice que «does not escape», y aun así sized(5) asigna memoria. El compilador reserva un pequeño búfer fijo en el frame y revisa n en tiempo de ejecución. Si el slice cabe, usa el búfer. Si no, asigna en el heap. El búfer por defecto es de 32 bytes, definido en cmd/compile/internal/base/flag.go, y eso alcanza para cuatro ints. Por eso 4 cabe y 5 no. Estos umbrales son detalles del compilador y pueden cambiar entre versiones.
El recolector de basura: marcar y después barrer
El recolector de basura de Go libera la memoria del heap a la que nada puede llegar. Según el comentario al principio de runtime/mgc.go, es un recolector concurrente de marcado y barrido (mark and sweep). No mueve objetos, y no divide el heap en generaciones.
Un ciclo tiene dos tareas principales. El marcado empieza en las raíces, que son el stack de cada goroutine y las variables globales. Sigue cada puntero y marca cada objeto al que llega. Después, el barrido recorre el heap, y la memoria de cada objeto que no quedó marcado queda disponible para reutilizarse. Las dos tareas corren junto a tu programa. El runtime sí detiene todas las goroutines en dos momentos: cuando empieza el marcado y cuando termina.
Go 1.26 activa por defecto una implementación renovada del marcado, llamada Green Tea. Cambia la forma en que el recolector recorre la memoria, no la idea de marcado y barrido que viene abajo.
Explicado como si tuvieras diez años
Cada caja del depósito empieza con una etiqueta blanca, que significa «nadie revisó esto todavía».
La persona de limpieza empieza por los escritorios. Cada caja a la que apunta una libreta recibe una etiqueta gris: «encontrada, pero todavía no miré adentro».
Después repite un solo movimiento. Elige una caja gris, la abre y le pone una etiqueta gris a cada caja a la que apuntan las etiquetas de adentro. Luego cambia la etiqueta de esa caja a negra: «encontrada y revisada por completo».
Cuando ya no quedan cajas grises, cada caja a la que alguien puede llegar es negra. Cada caja que sigue con etiqueta blanca es una a la que nadie puede llegar, así que se va con la basura.
La versión precisa
Eso es el marcado tricolor. Los objetos blancos no se han alcanzado, los grises se alcanzaron pero no se revisaron, y los negros ya se revisaron. El marcado empieza pintando de gris las raíces. El recolector toma un objeto gris, pinta de gris todo lo que apunta y lo vuelve negro. Cuando no quedan objetos grises, los blancos son inalcanzables, y la fase de barrido los libera.
Como tu programa sigue corriendo durante el marcado, puede cambiar punteros mientras el recolector trabaja. Podría guardar un puntero a un objeto blanco dentro de uno negro que el recolector ya terminó. Para evitar que ese objeto se libere por error, el runtime activa una write barrier (barrera de escritura) durante el marcado: cada escritura de un puntero también pinta de gris los punteros involucrados. Los objetos que se asignan durante el marcado se marcan negros de inmediato.
Dónde falla la analogía: la persona de limpieza de la historia trabaja sola mientras todos esperan. El recolector real corre al mismo tiempo que tu programa, en varios hilos, y el propio programa hace parte del marcado cuando asigna memoria. Y las cajas no tienen etiquetas: las marcas son bits que el runtime guarda junto al heap.
Dos controles: GOGC y GOMEMLIMIT
El runtime de Go tiene dos ajustes que controlan cada cuánto corre el GC. Los dos son variables de entorno, y los dos tienen una función en runtime/debug que los cambia mientras el programa se ejecuta:
package main
import (
"fmt"
"math"
"runtime/debug"
)
func main() {
oldPercent := debug.SetGCPercent(100)
oldLimit := debug.SetMemoryLimit(-1)
fmt.Println("GOGC:", oldPercent)
fmt.Println("GOMEMLIMIT is off:", oldLimit == math.MaxInt64)
}
Imprime:
GOGC: 100
GOMEMLIMIT is off: true
Cada setter devuelve el valor anterior, y un límite de memoria negativo lee el límite sin cambiarlo. Sin ninguna de las dos variables definidas, el programa reporta los valores por defecto. Ejecútalo con GOGC=50 GOMEMLIMIT=1GiB en el entorno y las dos líneas cambian.
GOGC es un porcentaje. La documentación del runtime dice que una recolección empieza cuando la memoria asignada desde la anterior llega a ese porcentaje de los datos vivos que quedaron después de ella. Con el valor por defecto de 100, el heap puede crecer hasta más o menos el doble de su tamaño vivo antes del siguiente ciclo. Un valor más alto significa menos recolecciones y más memoria. Uno más bajo significa más recolecciones y menos memoria. GOGC=off apaga el recolector.
GOMEMLIMIT es un límite blando sobre la memoria total que administra el runtime de Go, escrito en bytes con un sufijo opcional como MiB o GiB. A medida que el programa se acerca al límite, el GC corre más seguido para mantenerse por debajo. Viene desactivado por defecto, y eso es lo que significa math.MaxInt64. La documentación de SetMemoryLimit advierte que un límite por debajo de lo que el programa realmente necesita puede hacer que el GC corra casi sin parar. Es un límite blando, no un tope estricto.
También puedes leer estadísticas de memoria con runtime.ReadMemStats. Campos como HeapAlloc (bytes que hay ahora en el heap), TotalAlloc (bytes asignados en total) y NumGC (ciclos completados) son útiles en una sesión de depuración. Aquí no hay salida, porque los números cambian de una ejecución a otra.
Qué hacer con todo esto
La mayor parte del código Go nunca necesita nada de esto. Estos son los consejos que se sostienen.
No optimices sin un profile. Una asignación en código que se ejecuta una sola vez al arrancar no te cuesta nada que importe. Mide primero, con un benchmark ejecutado como go test -bench . -benchmem o con AllocsPerRun como arriba, y cambia el código solo donde los números muestren un problema.
Reserva los slices de antemano cuando sabes el tamaño. Como mostró la parte sobre slices, make([]T, 0, n) evita hacer crecer el array subyacente una y otra vez.
Pasa los structs pequeños por valor. Un puntero no es automáticamente más barato. Copiar un struct pequeño es barato y nunca lo hace escapar, mientras que tomar su dirección sí puede hacerlo, si el compilador no puede demostrar que el puntero se queda local. Usa un puntero cuando la función tiene que cambiar el valor o el struct es grande, como explicó la parte sobre structs.
Qué recordar
- Cada llamada recibe un frame en el stack, que sale cuando retorna. Los stacks de las goroutines empiezan pequeños y crecen copiándose.
- Los valores que tienen que sobrevivir a su función van al heap, y el recolector de basura los libera cuando nada llega a ellos.
- Tú no eliges entre stack y heap. Lo decide el escape analysis, y devolver un puntero a una variable local es seguro.
go build -gcflags=-mmuestra las decisiones del compilador, ytesting.AllocsPerRuncuenta lo que realmente asigna memoria. El inlining puede cambiar la respuesta.- Los punteros que se devuelven o se guardan, las interfaces, los closures y los slices grandes o de tamaño desconocido son las causas habituales de escape.
- El GC es un mark and sweep tricolor y concurrente.
GOGCcambia memoria por CPU, yGOMEMLIMITfija un techo blando.
Primero escribe código claro, y deja que una medición te diga qué asignación quitar.