Gestión de fugas de memoria en Go: más allá del GC

El Garbage Collector (GC) de Go es excelente para evitar que pierdas memoria como en C, pero no es una solución mágica contra la mala gestión de referencias. Una fuga de memoria (memory leak) en Go no ocurre porque el GC “olvide” limpiar, sino porque el código mantiene referencias vivas a objetos que ya no se necesitan. El GC es un recolector de tipo mark-and-sweep: solo libera memoria que es inalcanzable desde las raíces (stacks de las goroutines, variables globales o registros). Si un objeto es alcanzable a través de un puntero, para el GC es “útil” y lo mantendrá en el heap, aunque tu lógica de negocio ya no lo utilice.

Esto ocurre principalmente por tres razones: goroutines que se quedan bloqueadas para siempre (goroutines leaks), colecciones como map que crecen indefinidamente sin limpieza, o el uso de time.Ticker que nunca se detiene con .Stop(). Si no controlas el ciclo de vida de tus procesos concurrentes, la memoria seguirá creciendo hasta que el sistema operativo mate tu proceso con un OOM (Out of Memory).

Para detectar esto en producción, no busques errores de compilación; busca tendencias. Debes monitorear el runtime.MemStats.HeapInuse y, sobre todo, utilizar perfiles de heap (pprof) comparando el inuse_objects entre dos puntos temporales para ver qué estructuras están creciendo sin detenerse.

package main

import (
	"context"
	"fmt"
	"runtime"
	"time"
)

// Job representa una carga de trabajo que simula el procesamiento de datos.
type Job struct {
	ID   int
	Data []byte
}

// worker saludable utiliza context para asegurar que la goroutine termine.
func worker(ctx context.Context, id int, job Job) {
	// Si el contexto se cancela, salimos. Esto evita que la goroutine quede viva.
	select {
	case <-ctx.Done():
		return
	case <-time.After(10 * time.Millisecond):
		// Simulamos procesamiento
		_ = job.Data[0]
	}
}

// leakingWorker simula una fuga de memoria clásica.
// La goroutine se queda bloqueada esperando un canal que nunca recibirá nada.
func leakingWorker(job Job) {
	ch := make(chan struct{})
	// Esta goroutine nunca terminará porque nadie enviará a 'ch' ni cerrará el canal.
	// Al quedarse bloqueada, su stack y 'job' permanecen en memoria para siempre.
	go func() {
		<-ch 
		fmt.Printf("Trabajo %d terminado (esto nunca pasará)\n", job.ID)
	}()
}

func printMemStats(label string) {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	// HeapAlloc es la memoria que el GC ha asignado actualmente.
	fmt.Printf("[%s] HeapAlloc: %v MiB\n", label, m.HeapAlloc/1024/1024)
}

func main() {
	printMemStats("Inicio")

	for i := 0; i < 100; i++ {
		// Creamos un job con 1MB de datos.
		job := Job{
			ID:   i,
			Data: make([]byte, 1*1024*1024),
		}

		// Ejecución sana: se usa contexto para limpiar recursos.
		ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
		worker(ctx, i, job)
		cancel()

		// Ejecución con fuga: la goroutine se queda bloqueada con el slice de 1MB.
		leakingWorker(job)

		if i%10 == 0 {
			printMemStats(fmt.Sprintf("Iteración %d", i))
		}
		time.Sleep(50 * time.Millisecond)
	}

	// Forzamos GC para limpiar lo que sea alcanzable y ver qué queda realmente.
	runtime.GC()
	time.Sleep(1 * time.Second)
	printMemStats("Final (tras GC manual)")
}

Análisis del flujo

En el ejemplo, la función leakingWorker es la responsable de la degradación del sistema. Cuando ejecutas leakingWorker(job), se lanza una goroutine que intenta leer de un canal ch. Como ese canal nunca se cierra ni recibe datos, la goroutine entra en un estado de espera indefinida.

Para el runtime de Go, esa goroutine es una “raíz” activa. Debido a que la goroutine está bloqueada en la línea <-ch, el compilador no puede descartar la variable job ni el slice Data que contiene, porque la goroutine podría despertarse en cualquier momento y necesitarlos. El resultado es que, en cada iteración del bucle main, estamos dejando 1 MiB de memoria “huérfano” en el heap, que el runtime.GC() es incapaz de reclamar porque técnicamente sigue siendo alcanzable.

Al final de la ejecución, notarás que HeapAlloc ha crecido de forma casi lineal con el número de iteraciones. La llamada a runtime.GC() limpia la memoria de los worker (que usaron context), pero no puede tocar la memoria de los leakingWorker, demostrando que la fuga no es del recolector, sino de la lógica de concurrencia.

El error frecuente

Un error muy sutil y común ocurre al manipular slices. Si tienes un slice gigante y extraes una porción pequeña, el nuevo slice pequeño apunta al mismo backing array (el arreglo subyacente) que el original.

func leakBySlice() {
	// Creamos un array de 100MB
	hugeData := make([]byte, 100*1024*1024)
	
	// Queremos solo los primeros 10 bytes para un log o un ID
	smallPart := hugeData[:10] 
	
	// El error: 'smallPart' mantiene vivo 'hugeData' en memoria.
	// Si guardas 'smallPart' en un cache global, ¡tienes 100MB de leak!
	keepInCache(smallPart)
}

Si necesitas que el pequeño sub-slice sea la única cosa que viva en memoria, debes copiar los datos a un nuevo slice: copy(newSlice, hugeData[:10]).

251

Dejar un comentario

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

Scroll al inicio