Gestión de memoria: Sintonía fina con GOGC y GOMEMLIMIT

El Garbage Collector (GC) de Go se comporta de forma reactiva basándose en la tasa de crecimiento de la memoria. El parámetro GOGC es la variable que dicta esta reactividad: define el ratio entre el heap vivo (memoria que aún se usa) y el tamaño del heap tras el último ciclo de recolección. Si GOGC es 100, el runtime disparará el GC cuando el heap alcance el doble del tamaño del heap vivo. Si subes este valor (por ejemplo, GOGC=200), el GC se ejecutará con menos frecuencia, ahorrando ciclos de CPU pero permitiendo que el proceso use más RAM. Si lo pones en off, desactivas el GC por completo, algo útil solo en benchmarks muy específicos donde la asignación es controlada.

Sin embargo, GOGC solo entiende de porcentajes de crecimiento, no de límites físicos. Aquí es donde entra GOMEMLIMIT [disponible desde Go 1.19], un límite superior de memoria “suave”. A diferencia de GOGC, que mira la tasa de cambio, GOMEMLIMIT mira la frontera absoluta. Si tu aplicación está cerca de alcanzar este límite, el runtime ignorará la lógica de GOGC y forzará ciclos de GC más agresivos para intentar mantenerse por debajo del umbral.

Debes usar GOGC para equilibrar la carga de CPU frente al consumo de memoria en entornos generales. Debes usar GOMEMLIMIT cuando despliegues en contenedores (como Kubernetes) donde el límite de memoria es estricto. Si te equivocas configurando GOGC hacia valores muy altos en un entorno con poca RAM, el proceso será eliminado por el OOM killer del sistema operativo. Si configuras un GOMEMLIMIT demasiado ajustado, entrarás en un bucle infinito de recolección de basura que consumirá toda la CPU sin liberar memoria útil.

package main

import (
	"fmt"
	"runtime"
	"runtime/debug"
	"time"
)

// hold mantiene las referencias vivas para que el GC no las recoja de inmediato
// y podamos observar cómo el heap crece y cómo se calcula el NextGC.
var hold [][]byte

func main() {
	// Simulamos GOGC=100 (valor por defecto).
	// El GC se activará cuando el heap sea el doble de la memoria viva.
	debug.SetGCPercent(100)

	fmt.Printf("%-20s | %-15s | %-15s\n", "Evento", "HeapAlloc (MB)", "NextGC Target (MB)")
	fmt.Println("-----------------------------------------------------------------------")

	// 1. Asignamos una base de memoria
	allocate(50)
	printStats("Post-Asignación 1")

	// 2. Forzamos un GC manual para establecer un "heap vivo" base limpio.
	runtime.GC()
	printStats("Post-GC Manual")

	// 3. Alocamos más memoria. El NextGC debería calcularse 
	// como: HeapAlloc + (HeapAlloc * GOGC / 100).
	allocate(30)
	printStats("Post-Asignación 2")

	// 4. Forzamos otro GC para ver cómo el objetivo (NextGC) se desplaza
	// según el nuevo tamaño de la memoria viva.
	runtime.GC()
	printStats("Post-GC Final")
}

func allocate(mb int) {
	// Creamos un slice de bytes y lo guardamos en la variable global 'hold'
	// para asegurar que la memoria permanezca en el heap y sea "viva".
	data := make([]byte, mb*1024*1024)
	for i := 0; i < 100; i++ {
		_ = data[i] // Evitamos optimizaciones del compilador
	}
	hold = append(hold, data)
	time.Sleep(100 * time.Millisecond)
}

func printStats(label string) {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("%-20s | %-15.2f | %-15.2f\n",
		label,
		float64(m.HeapAlloc)/1024/1024,
		float64(m.NextGC)/1024/1024,
	)
}

Desglose del comportamiento

En el ejemplo, observamos la métrica m.NextGC de runtime.MemStats, que es el campo más importante para entender la política de GOGC.

Cuando ejecutamos allocate(50), el HeapAlloc sube a 50MB. Como debug.SetGCPercent(100) establece un ratio de 1:1 sobre la memoria viva, el runtime calcula que el siguiente ciclo de basura debería ocurrir cuando el heap alcance los 100MB (los 50MB actuales + otro 100% de aumento). Esto es lo que ves reflejado en NextGC Target.

Al llamar a runtime.GC(), limpiamos la memoria que no es necesaria, pero como hemos guardado todo en la variable global hold, la memoria sigue siendo “viva”. En el último paso, al añadir otros 30MB, el HeapAlloc total es de 80MB (50 previos + 30 nuevos). El runtime actualiza el NextGC de forma dinámica: ahora el objetivo es 160MB (80MB actuales + 100% de aumento).

El error frecuente

Un error crítico al usar GOMEMLIMIT es la recirculación de GC (GC Thrashing). Si configuras un límite de memoria de 500MiB en un contenedor de Kubernetes, pero tu aplicación necesita 450MiB de memoria viva para operar normalmente, el runtime entrará en un estado de pánico.

El comportamiento será este: el GC se activa porque se acerca al límite de 500MiB, limpia la memoria basura, pero el HeapAlloc sigue estando por encima de lo que el límite permite debido a la memoria viva necesaria. El runtime, intentando desesperadamente evitar el OOM, disparará otro GC casi instantáneamente. El resultado es que la aplicación dedica el 90% de su tiempo de CPU a intentar liberar memoria que no puede liberar, congelando el servicio sin llegar a ser terminado por el sistema operativo.

173

Dejar un comentario

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

Scroll al inicio