Closures en Go: Captura por referencia y el ciclo de vida de las variables

Una closure (o clausura) es una función literal que “atrapa” las variables de su entorno léxico. A diferencia de otras funciones, una closure no solo contiene código, sino que mantiene una referencia a las variables de su ámbito original. Esto no es una copia de los valores; es un vínculo directo a la ubicación de memoria de la variable. Si la variable cambia fuera de la closure, la función verá el cambio; si la closure la modifica, el valor original se altera.

Esta arquitectura permite encapsular estado sin necesidad de recurrir a variables globales o estructuras de datos complejas, siendo ideal para implementar patrones como middleware, decorators o callbacks personalizados. Sin embargo, este comportamiento tiene un costo: la gestión de memoria. Cuando una closure captura una variable, el compilador realiza un “escape analysis” y, si la variable debe sobrevivir a la ejecución de la función que la definió, la moverá del stack al heap. Si usas closures de forma descuidada, especialmente dentro de goroutines, puedes causar condiciones de carrera (race conditions) o memory leaks, ya que la closure mantendrá con vida objetos pesados mucho más tiempo del esperado.

package main

import (
	"fmt"
	"sync"
)

// Task representa una unidad de trabajo con carga de datos.
type Task struct {
	ID   int
	Name string
}

func main() {
	var wg sync.WaitGroup
	counter := 0

	// increment es una closure que captura la variable 'counter' por referencia.
	// Cada vez que se ejecute, modificará la variable del scope superior.
	increment := func() {
		counter++
	}

	tasks := []Task{
		{ID: 1, Name: "Validar sesión"},
		{ID: 2, Name: "Consultar inventario"},
		{ID: 3, Name: "Procesar pago"},
	}

	fmt.Println("Iniciando procesamiento de tareas...")

	for _, task := range tasks {
		wg.Add(1)

		// Go 1.22 [disponible desde Go 1.22]:
		// En versiones anteriores, esta closure capturaba la variable 'task' por referencia.
		// Como 'task' era una única variable que se reasignaba en cada iteración,
		// casi todas las goroutines terminaban procesando la última tarea del slice.
		// Ahora, Go crea una instancia nueva de 'task' para cada iteración del loop.
		go func() {
			defer wg.Done()

			// Ejecutamos la closure capturada.
			increment()

			// Accedemos a 'task' y 'counter' desde dentro de la goroutine.
			fmt.Printf("Tarea completada: [ID: %d] %s | Contador global: %d\n", 
				task.ID, task.Name, counter)
		}()
	}

	wg.Wait()
	fmt.Printf("Proceso finalizado. Contador total: %d\n", counter)
}

En el ejemplo anterior, hemos configurado un sistema de procesamiento concurrente. La variable counter comienza en 0 y es capturada por la función anónima increment. Al ser una closure, increment no tiene su propia variable counter, sino que apunta a la dirección de memoria de la variable declarada en main.

Cuando el bucle for range recorre el slice tasks, ocurre algo crucial gracias a las mejoras de Go 1.22: en cada iteración, la variable task es una nueva variable de iteración. Esto evita el error histórico donde todas las goroutines compartían la misma dirección de memoria de la variable de control del bucle. Dentro de la goroutine, la ejecución de increment() modifica directamente counter, y la impresión final utiliza el valor actualizado. El uso de sync.WaitGroup es indispensable aquí; si no llamáramos a wg.Wait(), main terminaría antes de que las goroutines completen su trabajo, y no veríamos los resultados debido a la naturaleza asíncrona de las closures ejecutadas en hilos distintos.

El error frecuente

Un problema sutil y difícil de detectar es el memory leak causado por la captura excesiva de variables en closures de larga duración. Fíjate en este escenario:

func leakExample() {
    // Una estructura pesada que ocupa mucho espacio en el heap
    largeData := make([]byte, 1024*1024*100) // 100 MB
    
    // Creamos una goroutine que vivirá mucho tiempo
    go func() {
        for {
            time.Sleep(time.Hour)
            // ERROR: Esta closure captura 'largeData' de forma implícita.
            // Aunque la función 'leakExample' termine, 'largeData' NO será
            // recolectada por el Garbage Collector porque la goroutine 
            // mantiene una referencia activa a través de la closure.
            fmt.Println("Check:", len(largeData))
        }
    }()
}

Si una closure captura una variable, mantiene viva toda la estructura de datos a la que esa variable pertenece. Si lanzas una goroutine que se ejecuta en segundo plano (como un worker o un watcher) y esta captura una variable local de una función que ya terminó, esa variable —y todo lo que contiene— se queda atrapada en el heap permanentemente. Para evitarlo, lo más limpio es pasar solo los datos estrictamente necesarios como argumentos a la función de la goroutine, en lugar de capturar todo el entorno.

44

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio