Modelo de Memoria de Go: Pila, Montón y Semántica de Valor
Go es un lenguaje con recolección de basura y semántica de valor por defecto. Las variables viven en registros de activación que el compilador puede colocar en la pila, en el montón o en almacenamiento estático, pero rara vez eliges dónde. En cambio, eliges tipos: valores, punteros, slices, mapas e interfaces. Esos tipos determinan el comportamiento de copia, el aliasing y lo que el tiempo de ejecución debe rastrear para el recolector de basura.
Esta página es el ancla conceptual de la sección Tipos de Datos y Memoria. Fundamentos de Tipos de Datos muestra la sintaxis del día a día; los artículos sobre slices, mapas, interfaces y canales explican las representaciones del tiempo de ejecución. Aquí aprenderás por qué Go se siente como "pasar por valor", qué significa realmente "tipo de referencia" en Go y cómo difiere del modelo de memoria de concurrencia separado de Go (reglas de "happens-before" para goroutines).
Las variables de Go son valores a menos que uses un puntero; el compilador coloca el almacenamiento en la pila o en el montón mediante análisis de escape, y el GC recupera los objetos del montón inaccesibles.
Perspicacia: Los errores de aliasing, las copias inesperadas y los puntos calientes de asignación suelen provenir de malinterpretar la semántica de valor frente a la de puntero, no de memorizar marcos de pila.
Conceptos Clave:semántica de valor, puntero, análisis de escape, cabecera de slice/mapa/canal, valor cero, happens-before (concurrencia).
Cuándo Usar: Utiliza punteros cuando una función deba mutar el estado del llamador o cuando una estructura grande no deba copiarse en cada llamada; prefiere valores cuando la inmutabilidad y la claridad ganen.
Limitaciones/Compensaciones: No puedes forzar la asignación en la pila en el código fuente; el análisis de escape puede sorprenderte después de una refactorización; los punteros y las cabeceras compartidas aumentan el riesgo de aliasing y carreras.
Temas Relacionados: cabeceras de slice, buckets de mapas, itables de interfaces, herramientas de análisis de escape, detector de carreras.
Piensa en una variable de Go como una caja con nombre que contiene un valor de un tipo dado. La asignación copia los bits que constituyen el valor (para la mayoría de los tipos). Llamar a una función pasa una copia del argumento a menos que el tipo del parámetro sea un puntero, en cuyo caso la copia es el valor del puntero (una dirección), no una copia profunda de la estructura apuntada.
Go no expone malloc/free ni transferencia de propiedad al estilo Rust. La vida útil de la memoria está limitada por el alcance más la alcanzabilidad: cuando el último puntero a un objeto del montón desaparece, el GC lo recolecta. Ese es un contrato diferente a "este valor se mueve y el enlace anterior no es válido".
Pila (stack) y montón (heap) son ubicaciones de implementación, no palabras clave del lenguaje. Las variables locales que no escapan de una función a menudo viven en la pila de la goroutine. Los valores cuyas direcciones sobreviven al marco (punteros devueltos, cierres que capturan locales, almacenamiento de punteros en globales) se mueven al montón. No anotas esto; go build -gcflags=-m informa lo que el compilador decidió.
Varios tipos integrados son similares a referencias pero aún se pasan por valor: un slice es una pequeña cabecera (puntero, longitud, capacidad) que se copia en la asignación; los mapas y canales son descriptores que apuntan a estructuras del tiempo de ejecución. Copiar la cabecera duplica el manejador, no los datos de respaldo; dos variables pueden observar el mismo array o tabla hash.
El análisis de escape se ejecuta por función durante la compilación. Si el compilador no puede probar que la dirección de un valor no se filtra, escapa al montón. Los desencadenantes comunes de escape son: devolver &local, asignar &local a una interfaz {}, añadir &local a un slice de punteros, o capturar una variable en una goroutine que pueda sobrevivir al marco.
marco del llamador marco del llamado
┌─────────────┐ ┌─────────────┐
│ s := []int │ copia hdr │ fn(s []int) │
│ {1,2,3} │ ─────────►│ usa cabecera│
└──────┬──────┘ └─────────────┘
│
▼
array de respaldo (montón o pila - decide el compilador)
La semántica de valor brilla en el diseño de API: time.Time, estructuras pequeñas e IDs se copian de forma económica y reducen la mutación oculta. La semántica de puntero intercambia la copia por el estado compartido: *Config mutado en un paquete afecta a todos los poseedores de ese puntero. Los slices se sitúan en el medio: la cabecera se copia, el array de respaldo se comparte hasta que append reasigna.
El modelo de memoria de concurrencia de Go (documentado en go.dev/ref/mem) es ortogonal a la pila/montón. Define qué lecturas y escrituras están permitidas entre goroutines mediante sincronización: las operaciones de canal, sync.Mutex, sync/atomic y sync.Once establecen bordes de happens-before. Una carrera de datos (data race) son dos goroutines accediendo a la misma memoria, al menos una escritura, sin sincronización. El detector de carreras las encuentra en tiempo de ejecución; el análisis de escape no.
Enfoque
Fortaleza
Debilidad
Mejor Ajuste
Pasar struct por valor
Propiedad clara, sin nil, menos carreras
Costo de copia para structs grandes
Registros inmutables pequeños, enums, coordenadas
Pasar *T
Instancia compartida única, mutación in situ
Comprobaciones de nil, aliasing, riesgo de carrera
Configuraciones grandes, constructores, analizadores con estado
Cabecera de slice por valor
Paso económico, array de respaldo compartido
Sorpresas de aliasing de sub-slice
Colecciones, buffers, E/S de streaming
Descriptor de map/chan
Mapas y señalización compartidos idiomáticos
No seguro para carreras de mapas no sincronizadas
Pipelines concurrentes con sincronización adecuada
La micro-optimización de "mantenerlo en la pila" sin perfilar suele ser un esfuerzo perdido. El Go moderno inlining objetos pequeños y el GC Green Tea (predeterminado en Go 1.26) reduce la sensibilidad a las pausas para muchas cargas de trabajo. Cuando los perfiles de asignación muestran rutas calientes, las correcciones suelen ser: reutilizar buffers con sync.Pool, preasignar slices (make([]T, 0, n)), pasar punteros solo donde sea necesario o reducir el "boxing" a interface{}.
Los valores cero son importantes: los slices nil y los mapas nil se comportan de manera diferente (la escritura en un mapa nil entra en pánico; la adición a un slice nil funciona). Las interfaces que contienen punteros nil tipados no son iguales a una interfaz nil, un error de producción común cubierto en Interfaz Nil vs Puntero Nil.
Para servicios, la semántica de valor en los límites del dominio (devolver copias, DTOs inmutables) más punteros solo dentro de paquetes críticos para el rendimiento es un patrón duradero. Combina eso con el detector de carreras en CI y la disciplina de mutex/canal de Fundamentos de Concurrencia.
"Go pasa argumentos de función por referencia porque los slices son referencias" - Los parámetros siempre se pasan por valor; los valores de slice/mapa/canal son cabeceras pequeñas cuya copia aún comparte el almacenamiento de respaldo.
"Puedo poner structs grandes en la pila con una palabra clave" - Solo el análisis de escape decide; new(T) y &T{} aún pueden asignarse en la pila si no escapan.
"Los punteros siempre significan montón" - Los punteros que no escapan pueden apuntar a memoria de pila; montón vs pila es sobre la vida útil, no la sintaxis *.
"El documento del modelo de memoria trata sobre pila y montón" - go.dev/ref/mem trata sobre la visibilidad de goroutine (happens-before), no sobre la colocación de asignaciones.
"Copiar un slice copia los datos" - copy(dst, src) o append a un nuevo slice pueden copiar elementos; la asignación solo copia la cabecera.
¿Go pasa los argumentos de función por referencia o por valor?
Siempre por valor. Si el parámetro es *T, el valor copiado es el puntero (dirección), no la estructura en sí. Las mutaciones a través de ese puntero son visibles para todo el código que comparte la misma dirección.
¿Qué es el análisis de escape en una frase?
El análisis del compilador que decide si el almacenamiento de una variable debe sobrevivir a su función declarante y, por lo tanto, debe asignarse en el montón.
¿Cuándo debo usar un receptor de puntero frente a un receptor de valor?
Usa receptores de puntero cuando los métodos mutan el estado, cuando la estructura es lo suficientemente grande como para que la copia sea costosa, o cuando la consistencia requiere todos los métodos en *T. Los receptores de valor son adecuados para tipos inmutables pequeños y evitan mutaciones accidentales.
¿Se pasan los mapas por referencia?
Las variables de mapa son descriptores que se pasan por valor. La asignación o el paso a una función copian el descriptor, no la tabla de buckets. Ambas copias se refieren a los mismos datos del mapa.
¿Cuál es el valor cero de un puntero?
nil. Desreferenciar sin una comprobación de nil entra en pánico. El valor cero de *int es nil, no un puntero a cero.
¿Cómo se relaciona el modelo de memoria de concurrencia de Go con la pila y el montón?
Son temas separados. La colocación de la asignación es tiempo de compilación + GC; el modelo de memoria rige qué lecturas/escrituras entre goroutines están definidas cuando usas primitivas de sincronización o canales.
¿Por qué funciona devolver un puntero a una variable local?
Si el puntero escapa de la función, el compilador asigna la variable local en el montón para que permanezca válida después de la devolución. Si no escapa, el puntero puede referirse a almacenamiento de pila que solo es válido mientras el marco está activo.
¿Copian los datos las interfaces?
Asignar un valor de interfaz copia el par de palabras de la interfaz (tipo + puntero de datos). Los valores pequeños pueden vivir en línea en la palabra de datos; los valores más grandes viven en otro lugar y la interfaz apunta a ellos.
¿Qué herramientas muestran las decisiones de pila vs montón?
go build -gcflags=-m registra las decisiones de escape. Los perfiles de CPU y asignación (pprof) muestran dónde aterriza el costo del tiempo de ejecución; úsalos antes de reescribir tipos.
¿Es la asignación en pila más rápida que en montón?
A menudo sí para objetos pequeños que no escapan, ya que la asignación en montón implica el asignador y la contabilidad del GC. La diferencia varía según la versión de Go y la carga de trabajo; mide en lugar de asumir.
¿Pueden dos goroutines compartir una pila?
No. Cada goroutine tiene su propia pila (que crece según sea necesario). El compartir ocurre a través de objetos del montón, globales o acceso sincronizado a memoria a la que ambos hacen referencia.
¿Qué sucede cuando copio una struct que contiene un campo slice?
La copia de la struct duplica cada campo. Se copia la cabecera del campo slice, por lo que ambas structs ven el mismo array de respaldo hasta que un lado añade más allá de la capacidad y reasigna.
Versiones de Pila: Esta página fue escrita para Go 1.26.x (GC Green Tea por defecto, modernizadores go fix - verificar parche en la compilación), chi (última - verificar en la compilación), gin (última - verificar en la compilación), echo (última - verificar en la compilación), google.golang.org/grpc (última - verificar en la compilación), sigs.k8s.io/controller-runtime (última - verificar en la compilación), kubebuilder (última - verificar en la compilación), tinygo (últimos objetivos de placa - verificar en la compilación), wazero (última - verificar en la compilación) y golangci-lint (último conjunto de linters - verificar en la compilación).
cGJxcnRodnFyZi52YnxwdHZiNDEzfDIwMjYwNw==
Revisado por Chris St. John·Última actualización: 19 jul 2026