Node.js ha generado cierto debate y controversia en el mundo del desarrollo de backend a lo largo de los años. Aunque no se trata de un lenguaje de programación ni de un servidor, su uso como entorno de ejecución de JavaScript fuera del navegador ha generado muchas preguntas sobre su eficiencia y optimización de recursos. En este artículo, exploraremos el tema del multithreading en Node.js y descubriremos cómo los worker threads pueden llevar nuestras aplicaciones de backend al siguiente nivel.

El desafío del single threading en Node.js

El concepto de single threading en Node.js, como su nombre indica, se basa en la idea de que nuestro entorno de ejecución de JavaScript solo utiliza un único hilo de ejecución. Este enfoque simplifica ciertos aspectos del desarrollo, pero también presenta una serie de desafíos y limitaciones significativas cuando se trata de optimizar nuestras aplicaciones.

  • Utilización limitada de recursos: Por un lado, el single threading en Node.js limita la capacidad de aprovechar al máximo los recursos disponibles en una máquina moderna. A menudo, en sistemas con múltiples núcleos de CPU y grandes cantidades de memoria, esta limitación representa un desperdicio considerable de potencia de procesamiento y capacidad de almacenamiento. Como resultado, nuestras aplicaciones podrían ser más lentas y menos eficientes de lo que podrían ser si pudieran aprovechar todos los recursos disponibles.
  • El problema del bloqueo: Adicionalmente, el enfoque de single threading en Node.js también genera problemas cuando nuestras acciones son intensivas en el uso de CPU. Debido a que todas las operaciones se ejecutan en un único hilo, cualquier acción o cálculo que requiera una gran cantidad de tiempo de procesamiento bloqueará el event loop y, en última instancia, toda la aplicación. Esto puede llevar a una disminución del rendimiento y reducir la capacidad de nuestra aplicación para manejar solicitudes concurrentes.

Estrategias y soluciones tradicionales para el single threading

Diversas estrategias y herramientas han sido diseñadas e implementadas para abordar estas limitaciones. Algunas de las más conocidas y comunes incluyen la ejecución de múltiples procesos de Node.js, la introducción de balanceadores de carga y la adopción de administradores de procesos.

  • Múltiples procesos y balanceadores de carga: Una solución parcial al problema del single threading en Node.js es dividir el trabajo entre múltiples procesos. Cada proceso puede ejecutarse en un hilo independiente y estar configurado para utilizar un número diferente de recursos, como núcleos de CPU y memoria. A pesar de aumentar la eficiencia, esta solución implica elegir el balanceador de carga adecuado, como Nginx, para distribuir adecuadamente las solicitudes entrantes entre los procesos.
  • Administradores de procesos: Los administradores de procesos como PM2 han tenido éxito en ayudar a los desarrolladores de Node.js a manejar y optimizar sus aplicaciones en escenarios de single threading. Estas herramientas ofrecen capacidades como la administración del ciclo de vida del proceso, la capacidad de reanudar procesos fallidos e incluso la posibilidad de escalar automáticamente nuestra aplicación según la demanda.

No obstante, aunque estas estrategias y herramientas han sido útiles en cierta medida, no ofrecen una solución completamente satisfactoria a los desafíos y limitaciones presentados por el enfoque de single threading en Node.js. Y es aquí donde entran en juego los worker threads.

Demostración de la necesidad de multithreading

Para comprender por qué el multithreading es esencial en el desarrollo de aplicaciones eficientes en Node.js, realizaremos una demostración detallada de los problemas potenciales que puede enfrentar una aplicación en Node.js cuando utiliza el enfoque tradicional de thread único.

El precio de la asincronía

Node.js es conocido por su capacidad para manejar múltiples solicitudes de manera asincrónica a través del event loop del entorno de ejecución. Aunque esta asincronía es una ventaja considerable en ciertos casos, como la gestión de I/O y rendimiento en aplicaciones de servidor, puede convertirse en una limitación cuando se trata de realizar computaciones intensivas de CPU.

La función de Fibonacci

Para ilustrar esta limitación, considere el siguiente ejemplo en el que intentamos ejecutar una función Fibonacci de manera concurrente utilizando la naturaleza asíncrona de Node.js:

function fibonacci(num) {
  return num <= 1 ? num : fibonacci(num - 1) + fibonacci(num - 2);
}

function doFib(num) {
  return new Promise((resolve) => {
    resolve(fibonacci(num));
  });
}

Promise.all([doFib(40), doFib(40), doFib(40)]).then((results) => {
  console.log("Results:", results);
});

Aquí, la función fibonacci es una función recursiva que calcula el valor de la enésima posición en la serie de Fibonacci. Envolvemos esta función en una Promise con la función doFib para simular una ejecución asíncrona y concurrente.

Confrontando la ilusión

Al intentar ejecutar esta función de manera asíncrona, podríamos esperar que las tres invocaciones se ejecuten de forma concurrente, lo que nos permitiría obtener los resultados mucho más rápido que si se ejecutaran de manera secuencial. Sin embargo, aquí es donde surge el problema: Node.js es engañosamente eficiente y enmascara ciertos aspectos de su naturaleza de thread único.

Nuestra prueba demuestra que las invocaciones de la función doFib se ejecutan secuencialmente y no de manera concurrente, como podríamos haber esperado. Esto tiene un impacto significativo en el rendimiento y la eficiencia de nuestra aplicación, ya que bloquea el event loop y aumenta el tiempo de respuesta para las solicitudes entrantes.

Una solución al alcance: Multithreading

La incapacidad de ejecutar cálculos intensivos de CPU de forma asincrónica y concurrente demuestra la necesidad de incorporar el multithreading en nuestras aplicaciones Node.js. El multithreading nos permitiría aprovechar al máximo los recursos disponibles en una máquina y mejorar el rendimiento de nuestras aplicaciones al permitir que se ejecuten múltiples hilos de forma independiente y concurrente.

En la siguiente sección, exploraremos cómo los worker threads en Node.js pueden abordar esta necesidad y transformar nuestras aplicaciones en sistemas de alto rendimiento.

Introduciendo los worker threads en Node.js

Conceptos básicos de worker threads

Los worker threads representan una solución innovadora y poderosa para abordar los desafíos planteados por el single threading en Node.js. En esencia, los worker threads permiten a los desarrolladores dividir el trabajo de una aplicación en múltiples hilos de ejecución en lugar de ejecutar todo en un solo hilo. Esta característica es especialmente valiosa cuando se trata de realizar tareas intensivas en CPU, ya que evita que la aplicación completa quede bloqueada mientras se realiza un cálculo pesado.

Creación de worker threads

const { Worker } = require("worker_threads");

function doFib() {
  return new Promise((resolve, reject) => {
    const worker = new Worker("./fib.js");

    worker.on("message", (message) => {
      console.log(message);
      resolve(message);
    });

    worker.on("error", reject);
  });
}

Para crear un worker thread, primero hay que importar la clase Worker desde el módulo worker_threads de Node.js. Luego, se instancia un objeto Worker pasando la ruta del archivo que se ejecutará en el nuevo hilo. En este caso, se pasaría la ruta del archivo fib.js, donde se encuentra nuestra función de Fibonacci.

Los worker cuentan con event listeners asociados a eventos como "message" y "error", que permiten comunicarse con el hilo principal. Por ejemplo, si una tarea se completa correctamente, el worker thread puede enviar un mensaje al hilo principal utilizando el event listener "message". Si ocurre un error durante la ejecución, el event listener "error" proporciona una forma de notificar al hilo principal de dicho error.

Comunicación entre hilos

Como hemos visto, se pueden usar event listeners para establecer una comunicación entre diferentes hilos, pero ¿cómo se establece esta comunicación exactamente? La clave para comprender la comunicación entre hilos en Node.js está en los métodos postMessage() y on().

Enviar mensajes al worker thread

Para enviar un mensaje al worker thread, simplemente se utiliza el método postMessage() proporcionado por el objeto Worker.

worker.postMessage({ data: "Some data from main thread" });

Este método permite transmitir datos al worker thread para que los use durante su ejecución. Los datos enviados pueden ser objetos, arrays o cualquier estructura de datos compatible.

Recibir mensajes del worker thread

Para recibir mensajes del worker thread en el hilo principal, se utiliza el event listener "message".

worker.on("message", (message) => {
  console.log("Message from worker:", message);
});

Cuando el worker thread envía un mensaje al hilo principal utilizando parentPort.postMessage(data), este mensaje es recibido por el event listener "message" en el hilo principal.

Ventajas y desafíos de los worker threads

Los worker threads en Node.js ofrecen una serie de ventajas significativas en términos de rendimiento y escalabilidad. Al permitir la ejecución de tareas intensivas en CPU, evitan que otras partes importantes de la aplicación queden bloqueadas. Además, el uso de worker threads puede mejorar la eficiencia del uso de los recursos en una máquina que tenga múltiples núcleos de CPU disponibles.

Sin embargo, trabajar con worker threads también plantea desafíos diversos, como la necesidad de garantizar una comunicación adecuada y efectiva entre diferentes hilos y la sincronización del trabajo realizado en cada hilo con el hilo principal.

A pesar de estos desafíos, los worker threads representan una mejora notable en la capacidad de Node.js para manejar cargas de trabajo intensivas en CPU y son un testimonio del continuo desarrollo y crecimiento de este popular entorno de ejecución de JavaScript.

Compartir memoria entre hilos

Cuando trabajamos con worker threads en Node.js, es crucial abordar la cuestión del manejo eficiente de la memoria y la comunicación entre los distintos hilos de ejecución. En esta sección, profundizaremos en el concepto de compartir memoria entre hilos y exploraremos cómo los SharedArrayBuffer y otros métodos pueden ayudar a mejorar el rendimiento de nuestras aplicaciones de backend en Node.js.

Entendiendo los SharedArrayBuffer

Antes de adentrarnos en cómo utilizar efectivamente los SharedArrayBuffer, es útil comprender cómo funcionan. Un SharedArrayBuffer es un objeto especial introducido en JavaScript que representa un bloque de memoria compartida entre diferentes hilos. A diferencia de un ArrayBuffer normal, que es de propiedad exclusiva de un hilo, la memoria subyacente de un SharedArrayBuffer puede ser accesible y modificada por múltiples hilos al mismo tiempo.

Creando un SharedArrayBuffer

const sharedBuffer = new SharedArrayBuffer(16);

Para crear un SharedArrayBuffer, simplemente instanciamos un nuevo objeto SharedArrayBuffer y proporcionamos como argumento el tamaño en bytes de la memoria que queremos compartir. En el ejemplo anterior, hemos creado un SharedArrayBuffer de 16 bytes.

Utilizando SharedArrayBuffer con worker threads

Una vez que hemos creado un SharedArrayBuffer, podemos compartirlo entre diferentes hilos de ejecución en nuestra aplicación de Node.js utilizando worker threads. Veamos un ejemplo de cómo hacer esto en práctica.

Pasando un SharedArrayBuffer a un worker thread

const worker = new Worker("./w.js", { workerData: { sharedBuffer } });

Cuando creamos un nuevo worker thread, podemos pasarle datos de entrada a través de la propiedad workerData. En este caso, pasamos el SharedArrayBuffer como parte del objeto workerData.

Accediendo y modificando el SharedArrayBuffer desde un worker thread

const { sharedBuffer } = workerData;
const buf = new Uint32Array(sharedBuffer);
buf.fill(7);

Dentro del worker thread, podemos extraer el SharedArrayBuffer del objeto workerData y acceder a su contenido creando una vista con Uint32Array. Luego, podemos modificar directamente el contenido del SharedArrayBuffer utilizando métodos como fill().

Notificando al hilo principal sobre cambios en el SharedArrayBuffer

parentPort.postMessage("Done");

Una vez hemos hecho las modificaciones deseadas en el SharedArrayBuffer desde el worker thread, podemos utilizar el objeto parentPort para enviar un mensaje al hilo principal notificándole que las operaciones en el SharedArrayBuffer han finalizado.

Beneficios y aplicaciones prácticas

Compartir memoria entre hilos mediante el uso de SharedArrayBuffer y worker threads ofrece beneficios significativos en términos de rendimiento y eficiencia en aplicaciones de backend en Node.js. Al evitar la necesidad de copiar grandes bloques de memoria entre diferentes hilos y, en cambio, permitir que múltiples hilos accedan a la misma memoria de manera concurrente, se reducen los tiempos de ejecución y se aumenta la velocidad de nuestras aplicaciones.

Algunas aplicaciones prácticas de este enfoque incluyen el procesamiento de imágenes, la creación de miniaturas, la conversión de archivos PDF y muchas otras tareas que requieren una gran cantidad de cálculos y manipulación de datos.

En resumen, aprender a compartir memoria entre hilos utilizando SharedArrayBuffer y worker threads en Node.js puede abrir nuevas posibilidades para optimizar y mejorar el rendimiento de nuestras aplicaciones de backend, permitiéndonos aprovechar al máximo la potencia de Node.js para crear soluciones rápidas y eficientes.

Avances de Node.js y su impacto en el desarrollo backend

Node.js se ha tornado en una herramienta esencial en el desarrollo de backend. Sobre todo, su evolución reciente en términos de multithreading y eficiencia en el manejo de recursos tiene un impacto significativo en cómo se abordan las aplicaciones de backend con esta tecnología.

Mejor rendimiento gracias a los worker threads

La introducción de los worker threads en Node.js ha permitido superar la limitación del single threading. Los worker threads permiten ejecutar múltiples hilos en aplicaciones de backend, lo cual mejora enormemente la capacidad de manejo de solicitudes concurrentes y optimiza el uso del hardware disponible.

Estas ventajas se reflejan en tiempos de respuesta más rápidos, lo cual es crucial en entornos de alta demanda y aplicaciones con necesidades de procesamiento intensivo.

Eficiencia en la transferencia de datos

A través del uso de los SharedArrayBuffer, Node.js ha abierto la posibilidad de compartir memoria entre diferentes hilos de ejecución. Este avance en la eficiencia de la transferencia de datos posibilita la adopción de estrategias de procesamiento paralelo y agiliza la comunicación entre hilos.

Al eliminar la necesidad de copiar la data para compartirla entre hilos, Node.js permite a los desarrolladores enfocar sus esfuerzos en optimizar sus algoritmos y ofrecer soluciones más rápidas y robustas.

Ampliando el espectro de aplicaciones

Los avances en multithreading y compartición de memoria han ampliado el ámbito funcional de Node.js en el desarrollo de backend. Ahora, los desarrolladores pueden enfrentar con mayor confianza proyectos que involucren operaciones CPU intensivas, como manipulación de imágenes, conversión de archivos, simulaciones complejas y análisis de datos.

Esta mayor versatilidad significa que Node.js ahora es una opción viable en una gama más amplia de aplicaciones, lo cual fortalece su presencia en el ecosistema tecnológico.

Una comunidad en constante crecimiento

En tanto Node.js evoluciona y mejora, su comunidad de desarrolladores también se ve fortalecida. La creciente popularidad impulsa la aparición de paquetes, bibliotecas y marcos de trabajo especializados que facilitan el desarrollo de aplicaciones de backend de alta calidad.

Este constante flujo de conocimiento y herramientas contribuye al avance de la tecnología y anima a más desarrolladores a adoptar Node.js como parte fundamental de su infraestructura de aplicaciones.

El futuro de Node.js en el desarrollo de backend

Con base en las mejoras destacadas, es evidente que la evolución de Node.js ha superado desafíos importantes y ahora representa una opción sólida y confiable para el desarrollo de aplicaciones de backend. Aunque no se espera que reemplace por completo a otros lenguajes de programación más especializados, hay claras señales de que Node.js ha alcanzado un nivel de madurez que le permitirá seguir siendo un componente esencial en la creación de soluciones innovadoras y eficientes.

Dicho esto, podemos esperar que Node.js siga creciendo en popularidad y se vuelva cada vez más importante en el panorama del desarrollo de backend.

Comparte esta publicación