La trampa de la variable del loop en Go es un comportamiento de alcance léxico (lexical scope) donde múltiples clausuras capturan la misma ubicación de memoria de una variable de iteración que se reutiliza en cada ciclo del bucle. Este fenómeno técnico provoca que, en ejecuciones diferidas o concurrentes, todas las funciones anónimas accedan al valor final de la variable tras la terminación del bucle, en lugar de capturar el valor específico de cada iteración.
Históricamente, este comportamiento existía en Go para optimizar la asignación de memoria, evitando la creación de una nueva variable en cada paso del for. Mientras que otros lenguajes implementan un scope de bloque que instancia nuevas referencias, Go priorizaba un modelo de eficiencia que, si bien reducía la presión sobre el recolector de basura, introducía riesgos de integridad de datos si el desarrollador no gestionaba manualmente el binding de las variables capturadas.
Mecánica de captura y persistencia de memoria
Cuando un closure referencia una variable externa, el compilador aplica un escape analysis para determinar si dicha variable debe residir en el heap. En un bucle for tradicional (previo a Go 1.22), la variable de iteración se declaraba una sola vez para toda la vida del bucle. Al lanzar una goroutine que utiliza dicha variable, el closure no captura el valor actual (copia), sino la dirección de memoria de la variable única. Dado que la ejecución de la goroutine es asíncrona, es altamente probable que el bucle avance o finalice antes de que la goroutine acceda al valor, encontrando siempre el estado final del iterador.
Para mitigar este riesgo en entornos de alta concurrencia o en versiones anteriores del lenguaje, se emplean dos patrones técnicos: el sombreado local (local shadowing) o el paso de parámetros por valor. Ambos métodos fuerzan la creación de una nueva instancia de la variable en el stack o heap para cada iteración, garantizando que cada clausura posea su propio contexto de datos inmutable por el avance del bucle.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
data := []string{"a", "b", "c"}
// PATRÓN 1: El error clásico (Comportamiento histórico)
for _, v := range data {
wg.Add(1)
go func() {
defer wg.Done()
// Captura la referencia de 'v'.
// Probable output: c c c
fmt.Print(v, " ")
}()
}
wg.Wait()
fmt.Println("\n---")
// PATRÓN 2: Solución mediante paso de parámetro
for _, v := range data {
wg.Add(1)
go func(val string) {
defer wg.Done()
// 'val' es una copia local del valor de 'v' en este ciclo
fmt.Print(val, " ")
}(v) // Se evalúa 'v' inmediatamente y se pasa la copia
}
wg.Wait()
fmt.Println("\n---")
// PATRÓN 3: Solución mediante sombreado (Shadowing)
for _, v := range data {
v := v // Nueva instancia de 'v' en este scope de bloque
wg.Add(1)
go func() {
defer wg.Done()
fmt.Print(v, " ") // Captura la 'v' local, no la del loop
}()
}
wg.Wait()
}
GoEl comportamiento más contraintuitivo aquí es que la declaración v := v dentro del bucle no es una redundancia, sino una instrucción específica para el compilador. Esta línea crea una nueva variable con el mismo nombre pero con un tiempo de vida restringido a la iteración actual, lo que permite que el closure capture esta nueva dirección de memoria única en lugar de la variable de control del bucle.
Cambio semántico en Go 1.22 y la gestión del heap
Un comportamiento no obvio del runtime actual es el cambio radical introducido en Go 1.22 respecto al alcance de las variables en bucles for. Desde esta versión, el compilador implementa de forma nativa la semántica de “variable por iteración”. Esto significa que el Patrón 1 del código anterior ahora se comporta de forma segura, imprimiendo “a b c” (en orden aleatorio por la concurrencia) sin necesidad de sombreado manual.
El edge case real persiste al trabajar con punteros o al mantener compatibilidad con versiones antiguas del compilador (o configuraciones de GOEXPERIMENT). Si un desarrollador captura la dirección de memoria de la variable de iteración mediante el operador & para almacenarla en un slice de punteros, debe ser consciente de que, aunque el compilador ahora cree nuevas variables por ciclo, el abuso de esta característica incrementará las asignaciones en el heap y la latencia del GC. La resolución implícita del scope es una conveniencia, pero no elimina la necesidad de comprender cómo el closure interactúa con el descriptor de la variable capturada.
- Módulo: Funciones
- Artículo número: #74