Garbage Collector y Memory Leaks en NodeJS
El Garbage Collector (GC) es un proceso automático que gestiona la liberación de memoria que ya no está siendo utilizada en las aplicaciones. En otras palabras, el GC se encarga de recuperar memoria que fue previamente asignada y que ya no es accesible, evitando que la aplicación agote sus recursos.
No solo maximiza la eficiencia en la gestión de recursos, especialmente en dispositivos con capacidades limitadas, sino que también protege nuestras aplicaciones. Sin él, el riesgo de memory leaks, que pueden agotar rápidamente la memoria y provocar fallos en las aplicaciones o en el sistema completo, aumentaría considerablemente.
Además, la presencia del Garbage Collector agiliza el desarrollo. Libera a los desarrolladores de la ardua tarea de gestionar manualmente la memoria, porque aunque actualmente los lenguajes de nueva generación hacen esto de forma automática, anteriormente esta solía ser una tarea manual y que todavía en lenguajes como C y C++ es requerido.
Relación con los Memory Leaks
Un "Memory Leak", o fuga de memoria, se produce cuando una porción de la memoria que fue asignada durante la ejecución de una aplicación no se libera después de haber dejado de ser útil o necesaria. En términos más simples, es como si reserváramos espacio para almacenar información, pero olvidáramos liberar ese espacio incluso después de que ya no necesitemos la información. Esta memoria, a pesar de no ser utilizada, queda "retenida" y, por ende, inaccesible para otros procesos.
El Garbage Collector (GC) actúa como un sistema de limpieza, identificando y liberando memoria que ya no está siendo utilizada. Sin embargo, no siempre puede identificar correctamente todos los memory leaks, especialmente si ciertos objetos o datos aún tienen referencias activas, aunque ya no sean necesarios. Es decir, si accidentalmente mantenemos referencias a objetos que ya no necesitamos, el GC no puede reconocerlos como "basura" y, por lo tanto, no puede liberar esa memoria.
El problema con los memory leaks es que pueden acumularse. Una pequeña fuga de memoria podría parecer inofensiva al principio, pero si ocurre repetidamente, digamos, en una función que se ejecuta con frecuencia, la cantidad de memoria "perdida" puede crecer rápidamente.
A medida que una aplicación consume más y más memoria debido a estas fugas, se presentan varios problemas. Primero, la aplicación puede comenzar a ralentizarse ya que el sistema podría comenzar a utilizar el almacenamiento secundario (como un disco duro) para compensar la falta de memoria disponible. Este proceso es mucho más lento que usar la memoria RAM. Además, si la memoria continúa agotándose, la aplicación, o incluso el sistema completo, podría fallar o bloquearse.
Tipos de recolección de basura en NodeJS
NodeJS, como muchos entornos que ejecutan JavaScript, utiliza el motor V8 de Google. Este motor emplea una técnica de recolección de basura generacional, basándose en la premisa de que la mayoría de los objetos se vuelven inaccesibles rápidamente (se convierten en "basura") mientras que otros permanecen en memoria durante un tiempo prolongado.
Para optimizar el proceso de limpieza, V8 divide la memoria del heap en dos segmentos o generaciones: joven y viejo.
Generacional
-
Zona Joven: Aquí es donde residen inicialmente los objetos recién creados. Es un espacio más pequeño y, debido a que contiene objetos de corta vida, su recolección es mucho más frecuente. Cada vez que se realiza esta recolección, los objetos que sobreviven (los que aún son referenciados y utilizados) se trasladan a la zona vieja.
let nuevoObjeto = {}; // Este objeto está en la zona joven, ya que acaba de ser creado.
-
Zona Vieja: Los objetos que han sobrevivido varias recolecciones en la zona joven son trasladados aquí. Esta zona es más grande y la recolección es menos frecuente, ya que se espera que los objetos aquí tengan una vida más larga. La limpieza en esta área es más costosa en términos de tiempo y recursos.
function repetir() { nuevoObjeto.data = new Array(1000).join('x'); // Si repetimos la función varias veces, el objeto podría ser trasladado a la zona vieja. }
Incremental y Limpieza al Detalle
Dado que recolectar toda la basura de una sola vez podría causar pausas notables en la ejecución de una aplicación (especialmente si hay mucho que limpiar), V8 utiliza técnicas como la recolección incremental y la limpieza al detalle. Estas técnicas dividen el proceso de recolección en pequeñas partes o fases, lo que permite intercalar la ejecución del programa con la recolección, reduciendo así las pausas perceptibles.
Compaction
Con el tiempo, la memoria puede fragmentarse. Imagina que tienes varios objetos en una línea y eliminas algunos en medio. Esto deja espacios vacíos entre los objetos restantes. La compactación es un proceso que "reordena" los objetos en memoria para aprovecharla de manera más eficiente, reduciendo estos espacios y haciendo que la asignación de nuevos objetos sea más rápida.
Es importante comprender estas técnicas y cómo V8 las implementa, ya que, aunque el proceso es en su mayoría automático, entenderlo puede ayudar a los desarrolladores a escribir código más optimizado y a diagnosticar problemas relacionados con la memoria.
¿Qué es eso del heap?
El "heap" en JavaScript es como un gran almacén donde se guardan todos los objetos y estructuras de datos que creamos en nuestro programa. Imagina que es una gran habitación donde cada vez que creamos un objeto, le asignamos un casillero o caja para guardarlo. Estos objetos se quedan en sus casilleros hasta que decidimos que ya no los necesitamos o hasta que el "conserje" del almacén (el Garbage Collector) nota que no los estamos usando y los saca para hacer espacio para objetos nuevos. Es el lugar donde se almacenan cosas que pueden crecer, cambiar de tamaño o que no sabemos cuánto tiempo necesitaremos.
¿Por qué se producen los Memory Leaks y cómo evitarlos?
Los memory leaks suelen ocurrir mayormente cuando escribimos mal nuestro código, por ej.:
1. Olvidamos deshacernos de objetos no deseados.
let largeObject = { data: new Array(1000000).join('x') };
function processData() {
// Procesar el objeto o cualquier otra operación temporal con el objeto largeObject
console.log(largeObject.data.length);
}
processData();
// Olvidamos poner: largeObject = null;
Después de usar largeObject
en processData()
, no lo establecimos como null
. Aunque ya no necesitamos el objeto, todavía ocupa una gran cantidad de memoria. Estableciendo el objeto como null
, le indicamos al Garbage Collector que puede liberar la memoria asociada con ese objeto.
2. Tenemos callbacks que no son eliminados.
const btn = document.getElementById('myButton');
btn.addEventListener('click', function() {
console.log('Botón presionado');
});
// En algún punto más tarde en el código...
// Supongamos que queremos reemplazar o eliminar el botón:
btn.remove();
// o simplemente cambias a otra vista o página
Aunque eliminamos el botón del DOM con btn.remove()
, el callback que se ejecuta cuando el botón es presionado todavía está en memoria. Esto es porque el event listener no se ha eliminado y todavía tiene una referencia al botón. Para evitar este memory leak, deberíamos usar btn.removeEventListener('click', callbackFunction)
antes de eliminar o reemplazar el botón.
Y particularmente este caso es bastante común porque aunque no vemos la ejecución del elemento porque obviamente el listener no se ha disparado, el evento esta en memoria a la escucha de acciones.
3. Estructuras de datos globales que siguen creciendo.
let globalDataStore = [];
function storeData(data) {
globalDataStore.push(data);
}
setInterval(() => {
const newData = fetchSomeData(); // Supongamos que esta función trae nuevos datos cada vez
storeData(newData);
}, 1000);
Aquí tenemos una estructura de datos global llamada globalDataStore
que sigue creciendo indefinidamente. Cada segundo, agregamos nuevos datos a la estructura sin nunca limpiarla o limitar su tamaño. Con el tiempo, esto puede conducir a un consumo excesivo de memoria, ya que globalDataStore
se llenará con más y más datos. Una posible solución sería limitar el tamaño de la estructura o limpiarla regularmente.
Consideraciones y Flags de NodeJS
NodeJS permite a los desarrolladores ajustar y configurar su entorno de ejecución mediante "flags" o banderas que se pueden especificar al iniciar un script. Estas banderas pueden ser útiles para solucionar problemas relacionados con la memoria, optimizar el rendimiento, o simplemente para experimentar y entender mejor cómo funciona NodeJS internamente. Vamos a explorar las banderas que mencionaste:
-
Limitar el tamaño del heap con
--max-old-space-size
:El heap, como hemos discutido anteriormente, es donde se almacenan los objetos y estructuras de datos dinámicos. En NodeJS, el tamaño predeterminado del heap puede variar según la arquitectura del sistema: por ejemplo, en sistemas de 64 bits, suele ser de 1.4 GB.
Si estás trabajando en una aplicación que consume mucha memoria o simplemente deseas limitar el uso de memoria de tu aplicación (por ejemplo, en contenedores o ambientes con memoria limitada), puedes usar el flag
--max-old-space-size
seguido del tamaño en megabytes que deseas asignar.node --max-old-space-size=512 script.js # Limita el heap a 512 MB.
Un ejemplo claro de consumo de memoria esta en este artículo, donde explicaba como procesar un archivo de 6GB usando Streams en lugar de simplemente ponerlo en la memoria, pues debido a la limitante de 1.4GB la aplicación fallaría.
-
Habilitar el Garbage Collector (GC) manualmente con
--expose-gc
:Por defecto, el Garbage Collector en NodeJS opera automáticamente en segundo plano. Sin embargo, en situaciones de desarrollo o pruebas, es posible que desees invocar el Garbage Collector manualmente para, por ejemplo, medir el uso de memoria antes y después de una limpieza.
El flag
--expose-gc
permite exponer la funcióngc()
en el global scope, permitiendo así invocar al Garbage Collector manualmente desde tu código.node --expose-gc script.js
Y dentro de
script.js
, podrías tener:if (global.gc) { global.gc(); // Invoca al Garbage Collector manualmente. } else { console.log('No se ha expuesto el GC.'); }
Es importante mencionar que invocar al GC manualmente en una aplicación en producción no suele ser una buena idea, ya que puede introducir pausas innecesarias y afectar el rendimiento. Sin embargo, para pruebas y diagnósticos, puede ser una herramienta valiosa.
Recomendaciones
Como recomendaciones generales que deberías seguir, agregaría lo siguiente:
-
Elimina referencias a objetos que ya no necesitas, como listeners o subscribers. Las referencias olvidadas pueden impedir la recolección de basura.
-
Cuando sea posible, almacena referencias débiles en lugar de referencias fuertes nos permite que el objeto sea recolectado como basura (WeakMap y WeakSet vs Map).
-
Podemos encapsular código que use objetos temporales dentro de funciones para limitar su alcance y duración.
-
Limitar el alcance de variables y estado a módulos o componentes es mejor que usar objetos globales.
-
En operaciones intensivas en memoria, recolectar la basura manualmente en intervalos regulares puede ayudar.
-
Usar linters para detectar patrones problemáticos y validar la gestión de memoria en testing también previene muchos issues comunes.
Conclusión
Los Garbage Collectors son esenciales para gestionar la memoria en nuestras aplicaciones, ayudan a asegurar que la memoria que ya no es necesaria sea liberada adecuadamente, previniendo problemas de rendimiento y fallos. Sin embargo, es crucial comprender cómo funciona y ser proactivo en evitar memory leaks para mantener nuestras aplicaciones funcionando de manera óptima.
Como siempre digo, espero que este artículo haya ilustrado el que es, como funciona y como prevenir errores futuros en tus aplicaciones.
Happy coding! :D
Photo by Daan Mooij on Unsplash