Skip to main content

Multihilos en Node.js con worker threads: ventajas y desventajas

Escrito por
Headshot of James Walker

James Walker

27 de febrero de 2023

0 minutos de lectura

Node.js presenta a tu aplicación un bucle de eventos de un solo hilo, lo que permite que las operaciones que exigen mucho CPU bloqueen el hilo principal y provoquen demoras. El módulo worker_threads aborda este problema al ofrecer un mecanismo para ejecutar código en paralelo mediante una forma de hilos.

En una publicación anterior, aprendiste qué son los worker threads, cuáles son sus casos de uso más comunes y cómo agregarlos a tu proyecto. En este artículo, veremos los inconvenientes de los worker threads y en qué se diferencian de las implementaciones de multihilos de otros lenguajes de programación. También repasaremos cinco bibliotecas destacadas que facilitan el uso del módulo worker_threads.

Inconvenientes y aspectos a tener en cuenta de los worker threads

Las ventajas de los worker threads se pueden resumir fácilmente: son la única forma de obtener algo similar al multihilo al programar con Node.js. Las operaciones que exigen mucho CPU, el procesamiento en segundo plano y cualquier ejecución de código en paralelo, excepto la E/S asíncrona, se pueden implementar con worker threads.

Sin embargo, el módulo y el concepto que implementa tienen varias salvedades. Debes conocerlas antes de empezar a implementar tus workers, ya que hay situaciones en las que no conviene paralelizar con este mecanismo.

Los worker threads no son hilos reales

La primera y más importante limitación de los worker threads es que no son hilos en el sentido convencional. Las aplicaciones verdaderamente multihilo permiten ejecutar varios hilos de forma simultánea y compartir el mismo estado de manera predeterminada. En Node.js, la memoria que se actualiza en un hilo no es visible para los demás, y para implementar código multihilo se requiere una gestión cuidadosa de la memoria a fin de evitar condiciones de carrera.

Los worker threads de Node.js funcionan independientemente del código JavaScript del proceso principal. Operan al iniciar una instancia aislada del entorno de ejecución de JavaScript V8 de Node. Luego, el nuevo entorno de ejecución se puede usar para ejecutar un archivo JavaScript fuera del bucle de eventos principal.

Como el archivo se ejecuta de esta manera, no hay uso compartido implícito de memoria entre el programa principal y el «hilo» worker. En su lugar, se ofrece un sistema de mensajería basado en eventos para intercambiar valores entre los procesos.

javascript
const {
    Worker,
    isMainThread,
    parentPort,
    workerData
} = require("worker_threads");

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: "hello"});
    worker.on("message", msg => console.log(`Worker message received: ${msg}`));
    worker.on("error", err => console.error(error));
    worker.on("exit", code => console.log(`Worker exited with code ${code}.`));
}
else {
    const data = workerData;
    parentPort.postMessage(`You said \"${data}\".`);
}

Este código, que analizaste en detalle en la primera parte, crea un proceso worker que recibe un valor (hello) del hilo principal y lo devuelve con una forma distinta (You said "hello".). La función postMessage() se usa para enviar datos al otro lado de la división entre el hilo principal y el worker. Las variables definidas en un lado no son visibles desde el otro.

Hay una excepción a esta regla: puedes usar un SharedArrayBuffer para compartir memoria directamente entre los hilos si la asignas específicamente como memoria compartida:

javascript
const {Worker, isMainThread, parentPort} = require("worker_threads");

// Allocate memory for 4 integers
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 4);
const arr = new Int32Array(sab);

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: arr});
    worker.on("message", msg => {
   	 if (msg.type === "update") {
   		 console.log(arr);
   	 }
    });
    worker.postMessage({type: "init", arr});
}
else {
    parentPort.on("message", msg => {
   	 if (msg.type === "init") {
   		 msg.arr[0] = 1001;
   		 parentPort.postMessage({type: "update"});
   	 }
    });
}

Si guardas este código en shared.js y ejecutas el archivo, se genera el siguiente resultado:

$ node shared.js
Int32Array(4) [ 1001, 0, 0, 0 ]

Esto funciona porque el worker thread puede acceder a la región de memoria compartida que creó el hilo principal. Cuando el hilo principal inspecciona los datos más tarde, ve los cambios que escribió el worker. Aun así, esto no equivale a compartir el estado, porque debes poner manualmente el arreglo a disposición del worker mediante la opción del constructor workerData.

Iniciar demasiados worker threads es costoso

Crear un worker thread no es lo mismo que iniciar un hilo nuevo en un lenguaje multihilo. Cada worker thread ejecuta su propia instancia del motor JavaScript V8, por lo que usar demasiados workers consumirá recursos considerables en tu host.

Aunque los workers se inician rápidamente, siempre hay un costo adicional asociado. Es una operación relativamente costosa, por lo que los worker threads no son adecuados para operaciones ligeras. Es mejor reservarlos para el procesamiento paralelo de actividades que exigen mucho CPU, donde el ahorro de tiempo de ejecución compensa con creces el costo de iniciar el proceso.

Puedes reducir las ineficiencias reutilizando un grupo de worker threads, lo que te permite evitar el costo de crear workers nuevos una y otra vez. Bibliotecas como Piscina y Poolifier abstraen la complejidad de administrar un grupo de workers.

Usar worker threads para la E/S es un desperdicio

Por su naturaleza, los worker threads no son adecuados para tareas de E/S. No necesitas worker threads para leer un archivo ni para obtener datos a través de la red: Node.js ya incluye alternativas asíncronas mejores.

La documentación de worker_threads recomienda específicamente no usar el módulo en estas situaciones. El costo de crear y mantener el proceso del worker con su propio motor V8 es mucho menos eficiente que las implementaciones de E/S asíncrona de Node. Si implementas estas tareas como worker threads, terminarás afectando el rendimiento, desperdiciando recursos y escribiendo código redundante.

Depurar worker threads puede ser complicado

Depurar grupos de worker threads puede ser complicado, porque no siempre hay una relación clara entre un evento, el worker que lo maneja y el efecto que produce. Intentar depurar lo que ocurre con instrucciones console.log() es tedioso y propenso a errores.

Puedes obtener información de diagnóstico más útil si adjuntas un AsyncResource a tu grupo. Esto proporciona trazas de pila asíncronas completas que registran lo que ocurre dentro del grupo y te permiten ver la secuencia completa de actividades que precede a un efecto.

Compartir memoria con un SharedArrayBuffer también puede generar problemas. Debes usar operaciones atómicas o implementar tu propio sistema de administración de concurrencia para evitar condiciones de carrera al acceder a la memoria compartida y modificarla. Si se producen condiciones de carrera, pueden causar síntomas extraños en tu aplicación y suelen ser difíciles de identificar, sobre todo cuando afectan a memoria que se usa en muchos lugares diferentes.

Principales bibliotecas de hilos para Node.js

El módulo integrado worker_threads de Node se enfoca en lo básico: crear worker threads e intercambiar datos con ellos. Estas son cinco bibliotecas populares que envuelven el módulo para ofrecer una interfaz más conveniente o funciones de nivel superior, como la agrupación de hilos.

Piscina

Piscina facilita el trabajo con grupos de workers. Puedes crear tus propias colas de tareas, hacer un seguimiento de su finalización y cancelar una tarea que se esté ejecutando en un worker si resulta redundante.

Este es un ejemplo sencillo de Piscina. Guarda este código en main.js:

javascript
const Piscina = require("piscina");
const piscina = new Piscina({filename: __dirname + "/worker.js"});

(async function() {
    const result = await piscina.run({msg: "hello"});
    console.log(result);    // "You said hello"
})();

Ahora agrega este código a worker.js:

javascript
module.exports = ({msg}) => `You said ${msg}`;

Instala el paquete Piscina con el siguiente comando:

$ npm install piscina

Cuando ejecutes node main.js, verás You said hello en la terminal. Piscina ofrece una interfaz más conveniente para la API de worker threads.

Bree

Bree es un programador de tareas para Node.js. Te permite ejecutar tareas asíncronas en un intervalo especificado. Puedes configurar límites de concurrencia, reintentos y cancelación para cada tarea. Bree usa worker threads internamente para ejecutar el código de las tareas fuera del bucle principal.

Instala Bree con npm:

$ npm install bree

Ahora crea un archivo llamado bree-main.js con el siguiente código:

javascript
const Bree = require("bree");

const bree = new Bree({
    jobs: [
   	 {
   		 name: "bree-job",
   		 interval: "5s",
   		 timeout: 0
   	 }
    ]
});

(async () => {
    await bree.start();
})();

Agrega el siguiente código a jobs/bree-job.js:

javascript
const d = new Date();
console.log(`The time is ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`);

Al ejecutar node bree-main.js, se mostrará la hora de inmediato y luego cada cinco segundos:

Worker for job "bree-job" online
The time is 11:45:30
Worker for job "bree-job" exited with code 0
Worker for job "bree-job" online
The time is 11:45:35
Worker for job "bree-job" exited with code 0

Poolifier

Poolifier es otra implementación de grupos de workers. Te permite manejar varios workers sin la complejidad de administrar el grupo por tu cuenta. Los grupos pueden ser fijos, es decir, contener una cantidad determinada de workers reutilizados, o dinámicos, lo que significa que se agregan workers según sea necesario hasta alcanzar el límite configurado por el usuario.

Puedes crear un grupo sencillo para ejecutar un archivo específico en un worker thread si agregas el siguiente código a main.js:

javascript
const {FixedThreadPool, DynamicThreadPool} = require("poolifier");

// 4 fixed workers
const fixedPool = new FixedThreadPool(4, __dirname + "/fixed-worker.js");
fixedPool.execute({}).then(res => {
    console.log(res);
}).catch(e => {
    console.error(e);
});

// Between 2 and 12 workers, dynamically
const dynamicPool = new DynamicThreadPool(2, 12, __dirname + "/dynamic-worker.js");
dynamicPool.execute({}).then(res => {
    console.log(res);
}).catch(e => {
    console.error(e);
});

Define el código que se ejecutará en el grupo fijo agregando el siguiente contenido a fixed-worker.js:

javascript
const {ThreadWorker} = require("poolifier");

module.exports = new ThreadWorker(
    () => "Running in the fixed thread pool",
    {
   	 async: false
    }
);

Ahora agrega el código que se ejecutará en el grupo dinámico a dynamic-worker.js:

javascript
const {ThreadWorker} = require("poolifier");

module.exports = new ThreadWorker(
    () => "Running in the dynamic thread pool",
    {
   	 async: false
    }
);

Instala el paquete poolifier desde npm y luego ejecuta main.js con Node. Deberías ver el siguiente resultado cuando ambos grupos de hilos se inicien y ejecuten sus tareas:

Running in the fixed thread pool
Running in the dynamic thread pool

El proceso seguirá ejecutándose hasta que lo termines presionando Ctrl+C. Poolifier mantiene disponibles los grupos de hilos para gestionar nuevas tareas y evita que el proceso termine mientras haya algún grupo activo.

Worker threads frente a otros lenguajes de programación

Cada lenguaje de programación implementa el multihilo de una manera diferente. En el caso de Node.js, se trata del sistema multiproceso que ofrece el módulo worker_threads. Esto es lo que ofrecen otros lenguajes.

  • C/C++: Como lenguajes de bajo nivel, C y C++ ofrecen multihilos reales mediante la biblioteca de hilos POSIX pthreads. C++ también incluye un objeto thread en su espacio de nombres estándar para facilitar aún más la concurrencia. Eres responsable de usar operaciones atómicas y mutexes para sincronizar correctamente la memoria y evitar condiciones de carrera.

  • Java: Java también ofrece multihilos reales mediante la clase Thread o su interfaz Runnable. Estos recursos cuentan con el respaldo de un completo conjunto de herramientas de concurrencia que te ayuda a administrar los hilos que creaste.

  • Python: Python tiene una biblioteca de hilos, pero CPython, la implementación más popular del lenguaje, solo puede ejecutar un hilo a la vez. Esto significa que, aunque el uso de hilos parece estar disponible, no acelerará el código que exige mucho CPU. Python también ofrece el módulo multiprocessing, que utiliza un enfoque similar al de los worker threads de Node.js.

  • Ruby: Ruby presenta una situación similar a la de Python. El lenguaje ofrece compatibilidad completa con hilos, pero MRI Ruby, su implementación más popular, solo admite un hilo a la vez. Los intérpretes alternativos más recientes, como JRuby y Rubinius, sí admiten multihilos reales.

  • Rust: Rust ofrece compatibilidad completa con multihilos. Te permite crear hilos y compartir datos entre ellos con un bajo riesgo de errores. El diseño del lenguaje hace imposibles muchos errores comunes de concurrencia, por lo que es una excelente opción para proyectos que dependerán mucho del multihilo.

El uso de hilos en C/C++, Rust y Java será mucho más rápido que el modelo de subprocesos de Node.js. Estos lenguajes ofrecen hilos reales, con estado compartido y todas las consideraciones de administración de memoria que esto implica. Los lenguajes interpretados de nivel superior, como Python, Ruby y Node.js, no ofrecen implementaciones nativas de hilos, sino que usan soluciones de subprocesos worker más pesadas.

En resumen

Los worker threads permiten que los desarrolladores de Node.js ejecuten código en paralelo al iniciar nuevos procesos secundarios. Sin embargo, esto no es multihilo real: cada «hilo» es un proceso independiente que no tiene acceso al contexto de su proceso principal. La comunicación entre hilos solo es posible mediante memoria compartida asignada y mensajes intercambiados a través de un detector de eventos.

El módulo worker_threads sigue siendo una parte invaluable del ecosistema de Node.js. No hay otra forma de lograr multihilo y procesamiento paralelo dentro de las limitaciones que impone JavaScript como lenguaje sincrónico y bloqueante. Sin embargo, es importante reconocer las limitaciones de los worker threads para que puedas decidir con criterio cuándo usarlos. Agregar un worker en una situación inadecuada podría reducir el rendimiento de tu aplicación y aumentar el uso de recursos.

El código que exige mucho CPU y para el que el rendimiento es crítico se ejecutará con mayor eficiencia en hilos reales de otro lenguaje de programación. Sin embargo, los worker threads son suficientes para la mayoría de los casos de uso de Node.js, como las colas de tareas en aplicaciones web o el procesamiento de video en segundo plano en tu computadora.

Leer más

Blog

Los modelos de frontera encontraron las vulnerabilidades. Solo el atacante encontró las cadenas.

El análisis estático encontró las fallas, pero solo las pruebas de ataque en vivo demostraron cómo podían encadenarse para provocar brechas. Una comparación de Evo COS, Claude Security y Claude Code Security.

feature insights context
Blog

Los ataques autónomos ya están aquí. La defensa debe estar a su altura.

Los atacantes autónomos están reduciendo el tiempo disponible para defenderse. Descubre cómo el descubrimiento, la corrección, la validación y la prevención continuos pueden ayudar a los equipos de seguridad a seguirles el ritmo.

Blog

Por qué los agentes de programación con IA siguen generando fallas de control de acceso

Los agentes de programación con IA pueden generar lógica de autorización que compila y supera la revisión, pero expone los datos de un inquilino a otro. Descubre por qué es difícil detectar el control de acceso roto y cómo prevenirlo.