Concurrencia en Node.js: Workers, Clusters y cómo combinarlos

Concurrencia en Node.js: Workers, Clusters y cómo combinarlos

Node.js es conocido por su modelo de programación no bloqueante basado en un bucle de eventos. Sin embargo, cuando se trata de aprovechar múltiples núcleos de CPU o realizar operaciones intensivas en CPU, es posible que necesite recurrir a técnicas más avanzadas como Workers y Clusters. Durante los días anteriores he estado escribiendo artículos en los que se hace uso de éstas técnicas e incluso el uso de Async/Await, pero me di cuenta de que quizás hayan conceptos que no estan muy claros y es por ello que decidí dedicar un artículo a explicarlo, sus ventajas y desventajas, y cómo se pueden combinar para crear aplicaciones más robustas y eficientes.

Workers en Node.js

¿Qué son?

Los Worker Threads en Node.js permiten realizar tareas de cómputo pesado sin bloquear el bucle de eventos principal. Estos son especialmente útiles para operaciones que requieren mucho tiempo de CPU.

Ventajas

  1. No bloquean el bucle de eventos principal: Permiten que las operaciones intensivas se ejecuten sin interrumpir otras tareas.
  2. Eficiencia: Hace un uso más eficiente del hardware, especialmente en sistemas con múltiples núcleos de CPU.

Desventajas

  1. Complejidad: Agregan una capa adicional de complejidad al manejar la comunicación entre el hilo principal y los hilos trabajadores.
  2. Gestión de estado: Mantener un estado coherente entre varios hilos puede ser desafiante.

Veamos un ejemplo

Este ejemplo utiliza multer para manejar la carga de archivos y guarda la imagen en disco antes de pasar la ruta de la imagen al worker para su procesamiento. El worker recibirá la ruta y procesará la imagen.

const { Worker, isMainThread, parentPort } = require('worker_threads');
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const app = express();

// Configuración de multer para manejar la carga de archivos
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });

if (isMainThread) {
  // Este bloque se ejecuta en el hilo principal

  // Creamos un servidor Express
  app.post('/upload', upload.single('imagen'), (req, res) => {
    // Obtenemos la imagen desde req.file.buffer
    const imagenBuffer = req.file.buffer;
    const rutaImagenTemporal = path.join(__dirname, 'temp', 'imagen.jpg');

    // Guardamos la imagen en el disco
    fs.writeFileSync(rutaImagenTemporal, imagenBuffer);

    // Creamos un nuevo worker y le pasamos la ruta de la imagen
    const worker = new Worker(__filename, { workerData: { rutaImagen: rutaImagenTemporal } });

    // Escuchamos por mensajes desde el worker
    worker.on('message', (msg) => {
      if (msg === 'done') {
        // Cuando el worker nos informa que ha terminado, enviamos una respuesta
        res.send('Imagen procesada y subida a S3');
      }
    });

    // Enviamos un mensaje al worker para que inicie el procesamiento de la imagen
    worker.postMessage('procesar_imagen');
  });

  // Iniciamos el servidor en el puerto 3000
  app.listen(3000, () => {
    console.log('Servidor escuchando en el puerto 3000');
  });
} else {
  // Este bloque se ejecuta en el worker creado

  // Escuchamos por mensajes desde el hilo principal
  parentPort.on('message', async (msg) => {
    if (msg === 'procesar_imagen') {
      // Accedemos a la ruta de la imagen enviada desde el hilo principal
      const { rutaImagen } = workerData;

      // Aquí puedes procesar la imagen (por ejemplo, subirla a S3) utilizando la ruta de la imagen
      // Después de procesarla, informamos al hilo principal que hemos terminado
      parentPort.postMessage('done');
    }
  });
}

La condición if (isMainThread) se utiliza para separar el código que debe ejecutarse en el hilo principal del código que debe ejecutarse en los workers. Cuando isMainThread es true, significa que el código se está ejecutando en el hilo principal, y cuando es false, significa que se está ejecutando en un worker. La clave para entender esto es comprender cómo funcionan los workers en Node.js y cómo se cargan los módulos.

  1. Hilo Principal: El hilo principal es el hilo de ejecución principal de una aplicación Node.js. Cuando inicia la aplicación, el código se ejecuta en este hilo. Puedes considerarlo como el "hilo principal" que controla la ejecución global de tu programa.

  2. Workers: Los workers son hilos adicionales que pueden ejecutar código de forma paralela al hilo principal. Cada worker tiene su propio flujo de ejecución y su propio contexto aislado, lo que permite realizar tareas en paralelo sin bloquear el hilo principal.

Cuando ejecutas un archivo JavaScript en Node.js, se carga una sola vez en memoria. Sin embargo, los workers no vuelven a cargar el mismo archivo JavaScript. En su lugar, se crea una nueva instancia del motor V8 de Node.js (el motor de JavaScript de Node.js) y se ejecuta el archivo JavaScript proporcionado en el worker. Esto significa que el archivo no se ejecuta nuevamente desde cero en el worker, sino que se ejecuta dentro del contexto del worker.

En el código de ejemplo:

if (isMainThread) {
  // Código ejecutado en el hilo principal
} else {
  // Código ejecutado en un worker
}

La primera parte del if se ejecutará cuando el archivo se cargue en el hilo principal, y la segunda parte (else) se ejecutará cuando el archivo se cargue en un worker. Esto permite que el mismo archivo sea compartido entre el hilo principal y los workers, y se ejecute de manera diferente según el contexto de ejecución, aunque perfectamente podríamos utilizar un archivo diferente para el worker.

Así que, no se ejecuta el archivo nuevamente cuando se verifica isMainThread; simplemente se determina en qué contexto se está ejecutando y se ejecuta el código correspondiente, específicamente este línea es la que hace que el archivo se vuelva a invocar, pero esta segunda vez será el worker el que responda:

const worker = new Worker(__filename, { workerData: { rutaImagen: rutaImagenTemporal } });

Clusters en Node.js

¿Qué son?

El módulo Cluster en Node.js permite crear múltiples instancias del mismo proceso para balancear la carga de trabajo. Esto es especialmente útil para aplicaciones en red, como servidores web.

Ventajas

  1. Balanceo de carga: Distribuye automáticamente las solicitudes entrantes entre varias instancias.
  2. Fácil de implementar: Menos complejo que implementar un sistema de balanceo de carga externo.

Desventajas

  1. No apto para tareas intensivas de CPU: Si una instancia se bloquea debido a una tarea intensiva, afecta a todas las solicitudes en esa instancia (entiéndase por instancia a una de las instancias creadas por el cluster, entonces si tienes más de una, solamente una de ellas se bloqueará, pero el resto seguirán funcionando sin problemas).
  2. Estado compartido: Coordinar el estado entre múltiples procesos puede ser complicado.

Veamos un ejemplo

const cluster = require('cluster');
const express = require('express');

// Verificamos si este proceso es el proceso maestro (master) o un trabajador (worker)
if (cluster.isMaster) {
  // Si es el proceso maestro, creamos varios workers
  const numWorkers = 4; // Número de workers que deseamos crear

  for (let i = 0; i < numWorkers; i++) {
    cluster.fork(); // Creamos un nuevo worker
  }
} else {
  // Si es un worker, este código se ejecuta en cada worker creado

  const app = express();

  app.post('/upload', async (req, res) => {
    // En este punto, cada worker maneja las solicitudes POST por separado
    // Aquí iría el código para procesar la imagen y subirla a S3

    // Enviamos una respuesta al cliente para indicar que la imagen ha sido procesada y subida
    res.send('Imagen procesada y subida a S3');
  });

  app.listen(3000, () => {
    console.log('Escuchando en el puerto 3000');
  });
}
  • cluster.isMaster verifica si el proceso actual es el proceso maestro (master) o un trabajador (worker). El proceso maestro es responsable de crear y gestionar los workers.

  • En el proceso maestro, un bucle for crea un número deseado de workers utilizando cluster.fork(). Cada worker representa una instancia separada de la aplicación.

  • En cada worker (cuando cluster.isMaster es false), se crea una instancia de la aplicación Express. Cada worker es independiente y puede manejar solicitudes de manera concurrente.

  • Cuando un cliente realiza una solicitud POST a /upload, el código en el bloque de ruta maneja la solicitud y procesa la imagen (en tu aplicación real, aquí es donde procesarías y subirías la imagen a S3).

El código anterior crea una aplicación Express que se ejecuta en varios workers. Cada worker es capaz de manejar solicitudes POST de manera independiente, lo que permite una distribución de carga efectiva en un entorno de múltiples núcleos de CPU. El proceso maestro crea y gestiona los workers, mientras que los workers reales manejan las solicitudes de manera individual.

Workers en ambas técnicas?

Es importante señalar que en el contexto de Node.js, los términos "workers" pueden ser confusos porque se utilizan en dos contextos diferentes:

  1. Workers en el módulo worker_threads: Estos son hilos de ejecución separados que se pueden utilizar para realizar tareas en paralelo dentro de una aplicación Node.js. Estos workers se crean utilizando el módulo worker_threads y se ejecutan en hilos independientes del hilo principal. Se utilizan para tareas intensivas en CPU o para procesar tareas en paralelo.

  2. Workers en el contexto de Clusters: En el contexto de Clusters en Node.js, los "workers" se refieren a instancias separadas de la aplicación principal que se crean para manejar solicitudes o tareas. Estos workers no son los mismos que los workers de worker_threads. En lugar de hilos independientes, estos workers son procesos separados que se ejecutan en el mismo servidor.

La confusión a menudo proviene del hecho de que ambos enfoques se denominan "workers" debido a que están relacionados con la distribución de tareas en paralelo. Sin embargo, son conceptos diferentes:

  • En el contexto de worker_threads, se utilizan hilos para realizar tareas en paralelo dentro de un proceso Node.js existente.
  • En el contexto de Clusters, se crean múltiples instancias separadas de la aplicación principal (llamadas workers) para manejar solicitudes o tareas en paralelo. Cada worker es un proceso Node.js independiente.

Entonces cuando hablo de Clusters, me refiero a la creación de múltiples instancias de la aplicación principal para manejar solicitudes en paralelo, y cuando hablo de worker_threads, me refiero a la creación de hilos de ejecución independientes dentro de un proceso Node.js. Ambos enfoques se utilizan para lograr la concurrencia y el paralelismo, pero funcionan de manera diferente a nivel de implementación.

Combinando Workers y Clusters

¿Por qué combinarlos?

Usar ambos te permite aprovechar las ventajas de cada uno. Se puede utilizar Cluster para manejar múltiples conexiones y Workers para el procesamiento real de las imágenes, cálculos pesados, etc.

Cómo combinarlos

  1. Inicializar Clusters: Crea múltiples instancias de tu aplicación utilizando Cluster.
  2. Utilizar Workers para tareas intensivas: Dentro de cada instancia, utiliza Worker para tareas que son intensivas en CPU.

Veamos un ejemplo con ambos conceptos

Este ejemplo es más completo y un poco desortenado quizás, pero la idea es que el módulo worker_threads se utiliza para procesar las imágenes cargadas en paralelo. Cada worker se encarga de manejar las solicitudes POST por separado y procesa las imágenes utilizando el módulo worker_threads.

const cluster = require('cluster');
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const app = express();

// Configuración de multer para manejar la carga de archivos
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });

// Verificamos si este proceso es el proceso maestro (master) o un trabajador (worker)
if (cluster.isMaster) {
  const numWorkers = 4; // Número de workers que deseamos crear

  // Creamos varios workers para manejar solicitudes concurrentes
  for (let i = 0; i < numWorkers; i++) {
    cluster.fork();
  }
} else {
  // Este bloque se ejecuta en cada worker creado

  const app = express();

  if (isMainThread) {
    // Código ejecutado en el hilo principal (solo una vez por worker)

    app.post('/upload', upload.single('imagen'), (req, res) => {
      // Obtenemos la imagen desde req.file.buffer
      const imagenBuffer = req.file.buffer;
      const rutaImagenTemporal = path.join(__dirname, 'temp', 'imagen.jpg');

      // Guardamos la imagen en el disco
      fs.writeFileSync(rutaImagenTemporal, imagenBuffer);

      // Creamos un nuevo worker y le pasamos la ruta de la imagen
      const worker = new Worker(__filename, { workerData: { rutaImagen: rutaImagenTemporal } });

      // Escuchamos por mensajes desde el worker
      worker.on('message', (msg) => {
        if (msg === 'done') {
          // Cuando el worker nos informa que ha terminado, enviamos una respuesta
          res.send('Imagen procesada y subida a S3');
        }
      });

      // Enviamos un mensaje al worker para que inicie el procesamiento de la imagen
      worker.postMessage('procesar_imagen');
    });

    app.listen(3000, () => {
      console.log('Worker escuchando en el puerto 3000');
    });
  } else {
    // Código ejecutado en cada worker (en paralelo)

    parentPort.on('message', async (msg) => {
      if (msg === 'procesar_imagen') {
        // Accedemos a la ruta de la imagen enviada desde el hilo principal
        const { rutaImagen } = workerData;

        // Aquí puedes procesar la imagen (por ejemplo, subirla a S3) utilizando la ruta de la imagen
        // Después de procesarla, informamos al hilo principal que hemos terminado
        parentPort.postMessage('done');
      }
    });
  }
}

Comparación: Workers vs Clusters vs Combinados

Criterio Workers Clusters Combinados
Uso de CPU Eficiente Variable Muy eficiente
Complejidad Media Baja Alta
Balanceo de carga No aplicable Automático Automático
Gestión de estado Complejo Complejo Muy complejo
Adecuado para Tareas de CPU Red/servidores Ambos

Conclusión

Tanto los Workers como los Clusters ofrecen maneras robustas y eficientes de manejar diferentes tipos de carga de trabajo en Node.js. Los ejemplos anteriores muestran cómo se pueden utilizar estas técnicas por separado o en conjunto para optimizar su aplicación para diferentes escenarios. Aunque la combinación de ambos puede añadir complejidad, los beneficios en rendimiento y eficiencia a menudo justifican el esfuerzo extra.

Espero que estos ejemplos te ayuden a comprender mejor cuándo y cómo utilizar Workers y Clusters en tus propios proyectos, mientras tanto, yo me he divertido mucho escribiendo este artículo por la cantidad de código que tuve que explicar.

Happy coding! :D


Photo by sol on Unsplash

Jack Fiallos

Jack Fiallos

Te gustó este artículo?