La retención de memoria en Go mediante slices es un fenómeno técnico donde un descriptor de longitud reducida mantiene una referencia persistente hacia la totalidad de su underlying array, impidiendo que el Garbage Collector (GC) reclame el espacio de memoria de elementos que han dejado de ser accesibles lógicamente. Este comportamiento se deriva de la estructura interna del slice, que funciona como una ventana o vista sobre un bloque de memoria contiguo, y no como una colección independiente de datos.
Este diseño existe en Go para priorizar el rendimiento y la eficiencia en la manipulación de datos. Al evitar la duplicación automática de memoria cada vez que se segmenta una colección, Go resuelve el problema del overhead computacional presente en lenguajes que realizan copias profundas por defecto. Sin embargo, esta optimización introduce una responsabilidad para el desarrollador: entender que un slice de 10 bytes puede “anclar” un array de 1 GB en el heap si el primero se originó como un sub-slice del segundo y permanece en el scope de la aplicación.
Mecánica del descriptor y persistencia en el heap
Un slice en Go es un encabezado ligero (slice header) compuesto por tres campos: un puntero a los datos, la longitud (len) y la capacidad (cap). Cuando se realiza una operación de reslicing como sub := original[0:10], el puntero de sub referencia una dirección de memoria específica dentro del array original. El runtime de Go considera que un bloque de memoria está “en uso” siempre que exista al menos un puntero activo hacia cualquier parte de dicho bloque.
El Garbage Collector de Go opera mediante un algoritmo de marcado y barrido (mark-and-sweep). Durante la fase de marcado, el GC sigue los punteros desde las raíces (como variables globales y el stack). Si encuentra un puntero dentro de un slice header, marca el bloque de memoria completo del array subyacente como alcanzable. En consecuencia, aunque solo estemos utilizando una fracción mínima del array, el resto de los datos —que podrían representar megabytes de información innecesaria— no serán liberados hasta que el sub-slice también salga de alcance y sea recolectado.
package main
import (
"fmt"
"runtime"
)
// simulateLargeData genera un slice de 10 millones de enteros
func simulateLargeData() []int {
return make([]int, 10_000_000)
}
func getSmallSegment() []int {
large := simulateLargeData()
// El sub-slice mantiene la referencia al array de 10M
return large[0:5]
}
func getSmallSegmentFixed() []int {
large := simulateLargeData()
// Creamos un nuevo backing array de tamaño exacto
small := make([]int, 5)
copy(small, large[0:5])
return small
}
func main() {
var m runtime.MemStats
// Caso con retención
res := getSmallSegment()
runtime.GC()
runtime.ReadMemStats(&m)
fmt.Printf("Alloc con retención: %v MB\n", m.Alloc/1024/1024) // → ~76 MB
// Caso optimizado
resFixed := getSmallSegmentFixed()
_ = resFixed
runtime.GC()
runtime.ReadMemStats(&m)
fmt.Printf("Alloc optimizado: %v MB\n", m.Alloc/1024/1024) // → ~0 MB
}
GoEl comportamiento más contraintuitivo es que, incluso si el slice original se establece como nil después del sub-slicing, el array subyacente no se libera si el sub-slice sigue vivo. La única forma de romper el vínculo de referencia es forzar la creación de un nuevo bloque de memoria mediante make y transferir los valores necesarios con copy.
El peligro de los punteros ocultos en el backing array
Un escenario crítico ocurre cuando el array subyacente contiene punteros a otros objetos (por ejemplo, un slice de structs o punteros a strings). Si realizamos un sub-slicing y mantenemos esa referencia, no solo estamos reteniendo el array principal, sino que todos los objetos referenciados por el “área invisible” (fuera de la longitud del sub-slice pero dentro de la capacidad del array) también permanecerán en memoria.
Este es un caso de fuga de memoria indirecta. Aunque el desarrollador crea que ha “limpiado” la colección al reducir su tamaño, los punteros residuales en el array subyacente siguen formando parte del grafo de alcanzabilidad del Garbage Collector. Para mitigar esto sin realizar una copia completa, es necesario “viciar” manualmente los elementos que quedan fuera del nuevo rango estableciéndolos a nil antes de realizar el reslicing, o bien optar por el patrón de copia si el volumen de datos retenidos en el array es significativamente mayor al requerido por la lógica de negocio.
Módulo: Colecciones y Memoria
Artículo número: #38