El autor seleccionó Open Sourcing Mental Illness para recibir una donación como parte del programa Write for DOnations.
Introducción
Node.js ejecuta código JavaScript en un solo hilo, lo que significa que su código solo puede realizar una tarea a la vez. Sin embargo, Node.js en sí mismo es multihilo y proporciona hilos ocultos a través de la biblioteca libuv
, que maneja operaciones de E/S como la lectura de archivos desde un disco o las solicitudes de red. A través del uso de hilos ocultos, Node.js proporciona métodos asíncronos que permiten que su código realice solicitudes de E/S sin bloquear el hilo principal.
Aunque Node.js tiene hilos ocultos, no se pueden utilizar para descargar tareas intensivas en CPU, como cálculos complejos, cambio de tamaño de imágenes o compresión de video. Dado que JavaScript es monohilo, cuando se ejecuta una tarea intensiva en CPU, bloquea el hilo principal y ningún otro código se ejecuta hasta que la tarea se completa. Sin utilizar otros hilos, la única forma de acelerar una tarea vinculada a la CPU es aumentar la velocidad del procesador.
Sin embargo, en los últimos años, las CPUs no han estado volviéndose más rápidas. En cambio, las computadoras se están enviando con núcleos adicionales, y ahora es más común que las computadoras tengan 8 o más núcleos. A pesar de esta tendencia, tu código no aprovechará los núcleos adicionales de tu computadora para acelerar tareas intensivas en la CPU o evitar romper el hilo principal porque JavaScript es de un solo hilo.
Para remediar esto, Node.js introdujo el módulo worker-threads
, que te permite crear hilos y ejecutar múltiples tareas de JavaScript en paralelo. Una vez que un hilo completa una tarea, envía un mensaje al hilo principal que contiene el resultado de la operación para que se pueda utilizar con otras partes del código. La ventaja de usar hilos de trabajo es que las tareas intensivas en la CPU no bloquean el hilo principal y puedes dividir y distribuir una tarea a varios trabajadores para optimizarla.
En este tutorial, crearás una aplicación Node.js con una tarea intensiva en la CPU que bloquea el hilo principal. A continuación, utilizarás el módulo worker-threads
para descargar la tarea intensiva en la CPU a otro hilo para evitar bloquear el hilo principal. Finalmente, dividirás la tarea intensiva en la CPU y tendrás cuatro hilos trabajando en paralelo para acelerar la tarea.
Prerrequisitos
Para completar este tutorial, necesitarás:
-
Un sistema multi-núcleo con cuatro o más núcleos. Aún puedes seguir el tutorial desde los Pasos 1 hasta el 6 en un sistema de doble núcleo. Sin embargo, el Paso 7 requiere cuatro núcleos para ver las mejoras de rendimiento.
-
Un entorno de desarrollo Node.js. Si estás en Ubuntu 22.04, instala la versión más reciente de Node.js siguiendo el paso 3 de Cómo instalar Node.js en Ubuntu 22.04. Si estás en otro sistema operativo, consulta Cómo instalar Node.js y crear un entorno de desarrollo local.
-
Un buen entendimiento del ciclo de eventos, las devoluciones de llamada y las promesas en JavaScript, que puedes encontrar en nuestro tutorial, Comprendiendo el ciclo de eventos, devoluciones de llamada, promesas y async/await en JavaScript.
-
Conocimientos básicos sobre cómo usar el framework web Express. Consulta nuestra guía, Cómo empezar con Node.js y Express.
Configuración del Proyecto e Instalación de Dependencias
En este paso, crearás el directorio del proyecto, inicializarás npm
, e instalarás todas las dependencias necesarias.
Para empezar, crea y muévete al directorio del proyecto:
- mkdir multi-threading_demo
- cd multi-threading_demo
El comando mkdir
crea un directorio y el comando cd
cambia el directorio de trabajo al recién creado.
Siguiendo esto, inicializa el directorio del proyecto con npm usando el comando npm init
:
- npm init -y
La opción -y
acepta todas las opciones por defecto.
Cuando el comando se ejecute, la salida será similar a esto:
Wrote to /home/sammy/multi-threading_demo/package.json:
{
"name": "multi-threading_demo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
A continuación, instala express
, un framework web de Node.js:
- npm install express
Usarás Express para crear una aplicación de servidor que tenga puntos de acceso bloqueantes y no bloqueantes.
Node.js incluye el módulo worker-threads
por defecto, así que no necesitas instalarlo.
Ahora has instalado los paquetes necesarios. A continuación, aprenderás más sobre procesos y hilos y cómo se ejecutan en una computadora.
Entendiendo Procesos y Hilos
Antes de empezar a escribir tareas que consumen CPU y delegarlas a hilos separados, primero necesitas entender qué son los procesos y los hilos, y las diferencias entre ellos. Lo más importante es revisar cómo los procesos y los hilos se ejecutan en un sistema informático de un solo o múltiples núcleos.
Proceso
A process is a running program in the operating system. It has its own memory and cannot see nor access the memory of other running programs. It also has an instruction pointer, which indicates the instruction currently being executed in a program. Only one task can be executed at a time.
Para entender esto, crearás un programa Node.js con un bucle infinito para que no salga cuando se ejecute.
Usando nano
, o tu editor de texto preferido, crea y abre el archivo process.js
:
- nano process.js
En tu archivo process.js
, introduce el siguiente código:
const process_name = process.argv.slice(2)[0];
count = 0;
while (true) {
count++;
if (count == 2000 || count == 4000) {
console.log(`${process_name}: ${count}`);
}
}
En la primera línea, la propiedad process.argv
devuelve un array que contiene los argumentos de la línea de comandos del programa. Luego, adjuntas el método slice()
de JavaScript con un argumento de 2
para hacer una copia superficial del array desde el índice 2 en adelante. Al hacerlo, se omiten los dos primeros argumentos, que son la ruta de Node.js y el nombre del programa. A continuación, usas la sintaxis de notación de corchetes para recuperar el primer argumento del array rebanado y almacenarlo en la variable process_name
.
Después de eso, defines un bucle while
y le pasas una condición true
para que el bucle se ejecute indefinidamente. Dentro del bucle, la variable count
se incrementa en 1
durante cada iteración. A continuación, hay una declaración if
que verifica si count
es igual a 2000
o 4000
. Si la condición se evalúa como verdadera, el método console.log()
registra un mensaje en la terminal.
Guarda y cierra tu archivo usando CTRL+X
, luego presiona Y
para guardar los cambios.
Ejecuta el programa usando el comando node
:
- node process.js A &
A
is a command-line argument that is passed to the program and stored in the process_name
variable. The &
at end the allows the Node program to run in the background, which lets you enter more commands in the shell.
Cuando ejecutas el programa, verás una salida similar a la siguiente:
Output[1] 7754
A: 2000
A: 4000
El número 7754
es un ID de proceso que el sistema operativo le asignó. A: 2000
y A: 4000
son las salidas del programa.
Cuando ejecutas un programa usando el comando node
, creas un proceso. El sistema operativo asigna memoria para el programa, localiza el ejecutable del programa en el disco de tu computadora y carga el programa en memoria. Luego, le asigna un ID de proceso y comienza a ejecutar el programa. En ese momento, tu programa se convierte en un proceso.
Cuando el proceso está en ejecución, su ID de proceso se agrega a la lista de procesos del sistema operativo y se puede ver con herramientas como htop
, top
, o ps
. Estas herramientas proporcionan más detalles sobre los procesos, así como opciones para detenerlos o priorizarlos.
Para obtener un resumen rápido de un proceso Node, presiona ENTER
en tu terminal para recuperar el indicador. A continuación, ejecuta el comando ps
para ver los procesos Node:
- ps |grep node
El comando ps
lista todos los procesos asociados con el usuario actual en el sistema. El operador de tubería |
pasa toda la salida de ps
al filtro grep
para filtrar los procesos y listar solo los procesos Node.
Al ejecutar el comando, obtendrás una salida similar a la siguiente:
Output7754 pts/0 00:21:49 node
Puedes crear innumerables procesos a partir de un solo programa. Por ejemplo, usa el siguiente comando para crear tres procesos más con diferentes argumentos y ponerlos en segundo plano:
- node process.js B & node process.js C & node process.js D &
En el comando, has creado tres instancias más del programa process.js
. El símbolo &
coloca cada proceso en segundo plano.
Al ejecutar el comando, la salida será similar a la siguiente (aunque el orden puede variar):
Output[2] 7821
[3] 7822
[4] 7823
D: 2000
D: 4000
B: 2000
B: 4000
C: 2000
C: 4000
Como puedes ver en la salida, cada proceso registró el nombre del proceso en la terminal cuando el recuento alcanzó 2000
y 4000
. Cada proceso no está al tanto de ningún otro proceso en ejecución: el proceso D
no está al tanto del proceso C
, y viceversa. Cualquier cosa que ocurra en cualquiera de los procesos no afectará a otros procesos de Node.js.
Si examinas la salida de cerca, verás que el orden de la salida no es el mismo que tenías cuando creaste los tres procesos. Cuando ejecutaste el comando, los argumentos de los procesos estaban en el orden de B
, C
y D
. Pero ahora, el orden es D
, B
y C
. La razón es que el sistema operativo tiene algoritmos de planificación que deciden qué proceso ejecutar en la CPU en un momento dado.
En una máquina de un solo núcleo, los procesos se ejecutan concurrentemente. Es decir, el sistema operativo cambia entre los procesos en intervalos regulares. Por ejemplo, el proceso D
se ejecuta durante un tiempo limitado, luego su estado se guarda en algún lugar y el sistema operativo programa el proceso B
para que se ejecute durante un tiempo limitado, y así sucesivamente. Esto sucede de ida y vuelta hasta que todas las tareas hayan terminado. A partir de la salida, puede parecer que cada proceso se ha ejecutado hasta su finalización, pero en realidad, el planificador del sistema operativo está constantemente cambiando entre ellos.
En un sistema multi-núcleo, suponiendo que tienes cuatro núcleos, el sistema operativo programa cada proceso para que se ejecute en cada núcleo al mismo tiempo. Esto se conoce como paralelismo. Sin embargo, si creas cuatro procesos más (totalizando ocho), cada núcleo ejecutará dos procesos concurrentemente hasta que terminen.
Hilos
Los hilos son como procesos: tienen su propio puntero de instrucciones y pueden ejecutar una tarea JavaScript a la vez. A diferencia de los procesos, los hilos no tienen su propia memoria. En cambio, residen dentro de la memoria de un proceso. Cuando creas un proceso, puedes tener varios hilos creados con el módulo worker_threads
ejecutando código JavaScript en paralelo. Además, los hilos pueden comunicarse entre sí mediante el paso de mensajes o compartiendo datos en la memoria del proceso. Esto los hace ligeros en comparación con los procesos, ya que la creación de un hilo no solicita más memoria al sistema operativo.
En cuanto a la ejecución de hilos, tienen un comportamiento similar al de los procesos. Si tienes varios hilos en un sistema de un solo núcleo, el sistema operativo alternará entre ellos en intervalos regulares, dando a cada hilo la oportunidad de ejecutarse directamente en la CPU única. En un sistema multinúcleo, el sistema operativo programa los hilos en todos los núcleos y ejecuta el código JavaScript al mismo tiempo. Si terminas creando más hilos de los que hay núcleos disponibles, cada núcleo ejecutará varios hilos simultáneamente.
Con eso, presiona ENTER
y luego detén todos los procesos Node en ejecución actualmente con el comando kill
:
- sudo kill -9 `pgrep node`
pgrep
devuelve los ID de proceso de los cuatro procesos Node al comando kill
. La opción -9
instruye a kill
a enviar una señal SIGKILL.
Cuando ejecutes el comando, verás una salida similar a la siguiente:
Output[1] Killed node process.js A
[2] Killed node process.js B
[3] Killed node process.js C
[4] Killed node process.js D
A veces, la salida podría retrasarse y aparecer cuando ejecutes otro comando más tarde.
Ahora que conoces la diferencia entre un proceso y un hilo, trabajarás con hilos ocultos de Node.js en la siguiente sección.
Comprendiendo los Hilos Ocultos en Node.js
Node.js sí proporciona hilos adicionales, por eso se considera multihilo. En esta sección, examinarás los hilos ocultos en Node.js, los cuales ayudan a hacer que las operaciones de E/S no sean bloqueantes.
Como se mencionó en la introducción, JavaScript es de un solo hilo y todo el código JavaScript se ejecuta en un único hilo. Esto incluye el código fuente de tu programa y las bibliotecas de terceros que incluyes en tu programa. Cuando un programa realiza una operación de E/S para leer un archivo o una solicitud de red, esto bloquea el hilo principal.
Sin embargo, Node.js implementa la biblioteca `libuv`, que proporciona cuatro hilos adicionales a un proceso de Node.js. Con estos hilos, las operaciones de E/S se manejan por separado y, cuando finalizan, el bucle de eventos agrega la devolución de llamada asociada con la tarea de E/S en una cola de microtareas. Cuando la pila de llamadas en el hilo principal está vacía, la devolución de llamada se coloca en la pila de llamadas y luego se ejecuta. Para dejar esto claro, la devolución de llamada asociada con la tarea de E/S dada no se ejecuta en paralelo; sin embargo, la tarea en sí de leer un archivo o una solicitud de red ocurre en paralelo con la ayuda de los hilos. Una vez que la tarea de E/S termina, la devolución de llamada se ejecuta en el hilo principal.
Además de estos cuatro hilos, el motor `V8` también proporciona dos hilos para manejar cosas como la recolección automática de basura. Esto eleva el número total de hilos en un proceso a siete: un hilo principal, cuatro hilos de Node.js y dos hilos de V8.
Para confirmar que cada proceso de Node.js tiene siete hilos, ejecute el archivo `process.js` nuevamente y póngalo en segundo plano:
- node process.js A &
La terminal registrará el ID del proceso, así como la salida del programa:
Output[1] 9933
A: 2000
A: 4000
Nota el ID del proceso en algún lugar y presiona `ENTER` para que puedas volver a usar el indicador.
Para ver los hilos, ejecute el comando `top` y pásale el ID del proceso mostrado en la salida:
- top -H -p 9933
La opción `-H` instruye a `top` a mostrar los hilos en un proceso. La bandera `-p` instruye a `top` a monitorear solo la actividad en el ID del proceso dado.
Cuando ejecutes el comando, la salida será similar a la siguiente:
Outputtop - 09:21:11 up 15:00, 1 user, load average: 0.99, 0.60, 0.26
Threads: 7 total, 1 running, 6 sleeping, 0 stopped, 0 zombie
%Cpu(s): 24.8 us, 0.3 sy, 0.0 ni, 75.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 7951.2 total, 6756.1 free, 248.4 used, 946.7 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 7457.4 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
9933 node-us+ 20 0 597936 51864 33956 R 99.9 0.6 4:19.64 node
9934 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node
9935 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.84 node
9936 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node
9937 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.93 node
9938 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.83 node
9939 node-us+ 20 0 597936 51864 33956 S 0.0 0.6 0:00.00 node
Como puedes ver en la salida, el proceso de Node.js tiene un total de siete hilos: un hilo principal para ejecutar JavaScript, cuatro hilos de Node.js y dos hilos de V8.
Como se discutió anteriormente, los cuatro hilos de Node.js se utilizan para operaciones de E/S para hacerlas no bloqueantes. Funcionan bien para esa tarea, y crear hilos por ti mismo para operaciones de E/S podría empeorar el rendimiento de tu aplicación. Lo mismo no puede decirse de las tareas vinculadas a la CPU. Una tarea vinculada a la CPU no utiliza hilos adicionales disponibles en el proceso y bloquea el hilo principal.
Ahora presiona q
para salir de top
y detener el proceso de Node con el siguiente comando:
- kill -9 9933
Ahora que conoces acerca de los hilos en un proceso de Node.js, escribirás una tarea vinculada a la CPU en la siguiente sección y observarás cómo afecta al hilo principal.
Creando una tarea vinculada a la CPU sin hilos de trabajadores
En esta sección, construirás una aplicación Express que tiene una ruta no bloqueante y una ruta bloqueante que ejecuta una tarea vinculada a la CPU.
Primero, abre index.js
en tu editor preferido:
- nano index.js
En tu archivo index.js
, agrega el siguiente código para crear un servidor básico:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
En el siguiente bloque de código, creas un servidor HTTP utilizando Express. En la primera línea, importas el módulo express
. A continuación, estableces la variable app
para contener una instancia de Express. Después de eso, defines la variable port
, que contiene el número de puerto en el que el servidor debe escuchar.
Después de esto, utilizas app.get('/non-blocking')
para definir la ruta a la que se deben enviar las solicitudes GET
. Finalmente, invocas el método app.listen()
para instruir al servidor que comience a escuchar en el puerto 3000
.
A continuación, define otra ruta, /blocking/
, que contendrá una tarea intensiva en la CPU:
...
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Defines la ruta /blocking
utilizando app.get("/blocking")
, que toma un callback asíncrono precedido por la palabra clave async
como segundo argumento que ejecuta una tarea intensiva en la CPU. Dentro del callback, creas un bucle for
que itera 20 mil millones de veces y durante cada iteración, incrementa la variable counter
en 1
. Esta tarea se ejecuta en la CPU y tomará unos segundos en completarse.
En este punto, tu archivo index.js
se verá así:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Guarda y cierra tu archivo, luego inicia el servidor con el siguiente comando:
- node index.js
Cuando ejecutas el comando, verás una salida similar a la siguiente:
OutputApp listening on port 3000
Esto muestra que el servidor está en ejecución y listo para servir.
Ahora, visita http://localhost:3000/non-blocking
en tu navegador preferido. Verás una respuesta instantánea con el mensaje This page is non-blocking
.
Nota: Si estás siguiendo el tutorial en un servidor remoto, puedes utilizar el reenvío de puertos para probar la aplicación en el navegador.
Mientras el servidor Express siga en funcionamiento, abre otra terminal en tu computadora local e ingresa el siguiente comando:
- ssh -L 3000:localhost:3000 your-non-root-user@yourserver-ip
Al conectarte al servidor, ve a http://localhost:3000/non-blocking
en el navegador web de tu máquina local. Mantén abierta la segunda terminal durante el resto de este tutorial.
A continuación, abre una nueva pestaña y visita http://localhost:3000/blocking
. Mientras la página carga, abre rápidamente dos pestañas más y visita http://localhost:3000/non-blocking
nuevamente. Verás que no obtendrás una respuesta instantánea y las páginas seguirán intentando cargar. Solo después de que la ruta /blocking
haya terminado de cargar y devuelva una respuesta result is 20000000000
, el resto de las rutas devolverán una respuesta.
La razón por la cual todas las rutas /non-blocking
no funcionan mientras la ruta /blocking
carga se debe al bucle for
vinculado a la CPU, que bloquea el hilo principal. Cuando el hilo principal está bloqueado, Node.js no puede atender ninguna solicitud hasta que la tarea vinculada a la CPU haya terminado. Entonces, si tu aplicación tiene miles de solicitudes simultáneas de tipo GET
a la ruta /non-blocking
, solo se necesita una visita a la ruta /blocking
para hacer que todas las rutas de la aplicación no respondan.
Como puedes ver, bloquear el hilo principal puede perjudicar la experiencia del usuario con tu aplicación. Para resolver este problema, deberás trasladar la tarea ligada a la CPU a otro hilo para que el hilo principal pueda seguir ocupándose de otras solicitudes HTTP.
Con eso, detén el servidor presionando CTRL+C
. Volverás a iniciar el servidor en la próxima sección después de hacer más cambios en el archivo index.js
. La razón por la que se detiene el servidor es que Node.js no se actualiza automáticamente cuando se realizan cambios en el archivo.
Ahora que entiendes el impacto negativo que una tarea intensiva para la CPU puede tener en tu aplicación, intentarás evitar bloquear el hilo principal usando promesas.
Trasladar una Tarea Ligada a la CPU Usando Promesas
A menudo, cuando los desarrolladores se enteran del efecto bloqueante de las tareas ligadas a la CPU, recurren a promesas para hacer que el código no sea bloqueante. Este instinto se basa en el conocimiento de usar métodos de E/S basados en promesas no bloqueantes, como readFile()
y writeFile()
. Pero como has aprendido, las operaciones de E/S hacen uso de hilos ocultos de Node.js, los cuales las tareas ligadas a la CPU no tienen. Sin embargo, en esta sección, envolverás la tarea ligada a la CPU en una promesa en un intento de hacerla no bloqueante. No funcionará, pero te ayudará a ver el valor de usar hilos de trabajador, lo cual harás en la próxima sección.
Abre nuevamente el archivo index.js
en tu editor:
- nano index.js
En tu archivo index.js
, elimina el código resaltado que contiene la tarea intensiva de la CPU:
...
app.get("/blocking", async (req, res) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
res.status(200).send(`result is ${counter}`);
});
...
A continuación, agrega el siguiente código resaltado que contiene una función que devuelve una promesa:
...
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
res.status(200).send(`result is ${counter}`);
}
La función calculateCount()
ahora contiene los cálculos que tenías en la función del controlador /blocking
. La función devuelve una promesa, que se inicializa con la sintaxis new Promise
. La promesa toma un callback con los parámetros resolve
y reject
, que manejan el éxito o el fracaso. Cuando el bucle for
termina de ejecutarse, la promesa se resuelve con el valor en la variable counter
.
A continuación, llama a la función calculateCount()
en la función del controlador /blocking/
en el archivo index.js
:
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
Aquí llamas a la función calculateCount()
con la palabra clave await
prefijada para esperar a que la promesa se resuelva. Una vez que la promesa se resuelve, la variable counter
se establece en el valor resuelto.
Tu código completo ahora se verá así:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Guarda y cierra tu archivo, luego vuelve a iniciar el servidor:
- node index.js
En tu navegador web, visita http://localhost:3000/blocking
y, mientras carga, recarga rápidamente las pestañas http://localhost:3000/non-blocking
. Como notarás, las rutas non-blocking
aún se ven afectadas y todas esperarán a que la ruta /blocking
termine de cargarse. Debido a que las rutas aún están afectadas, las promesas no hacen que el código JavaScript se ejecute en paralelo y no se pueden utilizar para convertir las tareas vinculadas a la CPU en no bloqueantes.
Con eso, detén el servidor de aplicaciones con CTRL+C
.
Ahora que sabes que las promesas no proporcionan ningún mecanismo para hacer que las tareas dependientes de la CPU no sean bloqueantes, usarás el módulo worker-threads
de Node.js para descargar una tarea dependiente de la CPU en un hilo separado.
Descargando una Tarea Dependiente de la CPU con el Módulo worker-threads
En esta sección, descargarás una tarea intensiva en la CPU a otro hilo usando el módulo worker-threads
para evitar bloquear el hilo principal. Para hacer esto, crearás un archivo worker.js
que contendrá la tarea intensiva en la CPU. En el archivo index.js
, usarás el módulo worker-threads
para inicializar el hilo y comenzar la tarea en el archivo worker.js
para ejecutarse en paralelo al hilo principal. Una vez que la tarea se complete, el hilo trabajador enviará un mensaje conteniendo el resultado de vuelta al hilo principal.
Para comenzar, verifica que tengas 2 o más núcleos usando el comando nproc
:
- nproc
Output4
Si muestra dos o más núcleos, puedes proceder con este paso.
A continuación, crea y abre el archivo worker.js
en tu editor de texto:
- nano worker.js
En tu archivo worker.js
, agrega el siguiente código para importar el módulo worker-threads
y realizar la tarea intensiva en la CPU:
const { parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
La primera línea carga el módulo worker_threads
y extrae la clase parentPort
. La clase proporciona métodos que puedes usar para enviar mensajes al hilo principal. A continuación, tienes la tarea intensiva de la CPU que actualmente se encuentra en la función calculateCount()
en el archivo index.js
. Más adelante en este paso, eliminarás esta función de index.js
.
Después de esto, agrega el código resaltado a continuación:
const { parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
parentPort.postMessage(counter);
Aquí invocas el método postMessage()
de la clase parentPort
, que envía un mensaje al hilo principal que contiene el resultado de la tarea vinculada a la CPU almacenada en la variable counter
.
Guarda y cierra tu archivo. Abre index.js
en tu editor de texto:
- nano index.js
Dado que ya tienes la tarea vinculada a la CPU en worker.js
, elimina el código resaltado de index.js
:
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
A continuación, en la devolución de llamada app.get("/blocking")
, agrega el siguiente código para inicializar el hilo:
const express = require("express");
const { Worker } = require("worker_threads");
...
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error occurred: ${msg}`);
});
});
...
Primero, importas el módulo worker_threads
y desempaquetas la clase Worker
. Dentro de la devolución de llamada app.get("/blocking")
, creas una instancia de Worker
usando la palabra clave new
seguida de una llamada a Worker
con la ruta del archivo worker.js
como argumento. Esto crea un nuevo hilo y el código en el archivo worker.js
comienza a ejecutarse en el hilo en otro núcleo.
A continuación, adjuntas un evento a la instancia de worker
utilizando el método on("message")
para escuchar el evento de mensaje. Cuando se recibe el mensaje que contiene el resultado del archivo worker.js
, se pasa como parámetro a la devolución de llamada del método, que devuelve una respuesta al usuario con el resultado de la tarea intensiva para la CPU.
Luego, adjuntas otro evento a la instancia del trabajador utilizando el método on("error")
para escuchar el evento de error. Si ocurre un error, la devolución de llamada devuelve una respuesta 404
que contiene el mensaje de error al usuario.
Ahora, tu archivo completo se verá así:
const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error occurred: ${msg}`);
});
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Guarda y sale del archivo, luego ejecuta el servidor:
- node index.js
Visita la pestaña http://localhost:3000/blocking
nuevamente en tu navegador web. Antes de que termine de cargar, actualiza todas las pestañas de http://localhost:3000/non-blocking
. Ahora deberías notar que se cargan instantáneamente sin esperar a que se complete la carga de la ruta /blocking
. Esto se debe a que la tarea intensiva para la CPU se traslada a otro hilo, y el hilo principal maneja todas las solicitudes entrantes.
Ahora, detén tu servidor usando CTRL+C
.
Ahora que puedes hacer que una tarea intensiva para la CPU sea no bloqueante utilizando un hilo de trabajador, usarás cuatro hilos de trabajador para mejorar el rendimiento de la tarea intensiva para la CPU.
Optimizando una Tarea Intensiva en CPU Utilizando Cuatro Hilos de Trabajo
En esta sección, dividirá la tarea intensiva en CPU entre cuatro hilos de trabajo para que puedan completar la tarea más rápido y acortar el tiempo de carga de la ruta /bloqueo
.
Para tener más hilos de trabajo trabajando en la misma tarea, deberá dividir las tareas. Dado que la tarea implica hacer un bucle 20 mil millones de veces, dividirá 20 mil millones entre el número de hilos que desea usar. En este caso, es 4
. Calcular 20_000_000_000 / 4
dará como resultado 5_000_000_000
. Entonces, cada hilo hará un bucle desde 0
hasta 5_000_000_000
e incrementará contador
en 1
. Cuando cada hilo termine, enviará un mensaje al hilo principal que contiene el resultado. Una vez que el hilo principal reciba los mensajes de los cuatro hilos por separado, combinará los resultados y enviará una respuesta al usuario.
También puede utilizar el mismo enfoque si tiene una tarea que itera sobre matrices grandes. Por ejemplo, si quisiera cambiar el tamaño de 800 imágenes en un directorio, puede crear una matriz que contenga todas las rutas de archivos de imagen. A continuación, divida 800
por 4
(la cantidad de hilos) y haga que cada hilo trabaje en un rango. El hilo uno cambiará el tamaño de las imágenes desde el índice de la matriz 0
hasta el 199
, el hilo dos desde el índice 200
hasta el 399
, y así sucesivamente.
Primero, verifica que tienes cuatro o más núcleos:
- nproc
Output4
Haz una copia del archivo worker.js
usando el comando cp
:
- cp worker.js four_workers.js
Los archivos actuales index.js
y worker.js
permanecerán intactos para que puedas ejecutarlos nuevamente y comparar su rendimiento con los cambios en esta sección más tarde.
A continuación, abre el archivo four_workers.js
en tu editor de texto:
- nano four_workers.js
En tu archivo four_workers.js
, agrega el código resaltado para importar el objeto workerData
:
const { workerData, parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000 / workerData.thread_count; i++) {
counter++;
}
parentPort.postMessage(counter);
Primero, extraes el objeto WorkerData
, que contendrá los datos pasados desde el hilo principal cuando se inicialice el hilo (lo que harás pronto en el archivo index.js
). El objeto tiene una propiedad thread_count
que contiene el número de hilos, que es 4
. Luego, en el bucle for
, se divide el valor 20_000_000_000
por 4
, lo que da como resultado 5_000_000_000
.
Guarda y cierra tu archivo, luego copia el archivo index.js
:
- cp index.js index_four_workers.js
Abre el archivo index_four_workers.js
en tu editor:
- nano index_four_workers.js
En tu archivo index_four_workers.js
, agrega el código resaltado para crear una instancia de hilo:
...
const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;
...
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
});
}
app.get("/blocking", async (req, res) => {
...
})
...
Primero, defines la constante THREAD_COUNT
que contiene el número de hilos que deseas crear. Más tarde, cuando tengas más núcleos en tu servidor, la escalabilidad implicará cambiar el valor de THREAD_COUNT
al número de hilos que desees utilizar.
A continuación, la función createWorker()
crea y devuelve una promesa. Dentro del callback de la promesa, inicializas un nuevo hilo pasando la clase Worker
el camino del archivo four_workers.js
como primer argumento. Luego pasas un objeto como segundo argumento. A continuación, asignas al objeto la propiedad workerData
que tiene otro objeto como su valor. Finalmente, asignas al objeto la propiedad thread_count
cuyo valor es el número de hilos en la constante THREAD_COUNT
. El objeto workerData
es aquel al que hiciste referencia en el archivo workers.js
anteriormente.
Para asegurarte de que la promesa se resuelva o lance un error, agrega las siguientes líneas resaltadas:
...
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
worker.on("message", (data) => {
resolve(data);
});
worker.on("error", (msg) => {
reject(`An error ocurred: ${msg}`);
});
});
}
...
Cuando el hilo del trabajador envía un mensaje al hilo principal, la promesa se resuelve con los datos devueltos. Sin embargo, si ocurre un error, la promesa devuelve un mensaje de error.
Ahora que has definido la función que inicializa un nuevo hilo y devuelve los datos del hilo, usarás la función en app.get("/blocking")
para generar nuevos hilos.
Pero primero, elimina el siguiente código resaltado, ya que ya has definido esta funcionalidad en la función createWorker()
:
...
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error ocurred: ${msg}`);
});
});
...
Con el código eliminado, agrega el siguiente código para inicializar cuatro hilos de trabajo:
...
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
});
...
Primero, creas una variable workerPromises
, que contiene un array vacío. A continuación, iteras tantas veces como el valor en THREAD_COUNT
, que es 4
. Durante cada iteración, llamas a la función createWorker()
para crear un nuevo hilo. Luego, insertas el objeto de promesa que devuelve la función en el array workerPromises
usando el método push
de JavaScript. Cuando el bucle termina, el array workerPromises
tendrá cuatro objetos de promesa, cada uno devuelto al llamar a la función createWorker()
cuatro veces.
Ahora, agrega el siguiente código resaltado a continuación para esperar a que se resuelvan las promesas y devolver una respuesta al usuario:
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
const thread_results = await Promise.all(workerPromises);
const total =
thread_results[0] +
thread_results[1] +
thread_results[2] +
thread_results[3];
res.status(200).send(`result is ${total}`);
});
Dado que el array workerPromises
contiene promesas devueltas al llamar a createWorker()
, prefijas el método Promise.all()
con la sintaxis await
y llamas al método all()
con workerPromises
como su argumento. El método Promise.all()
espera a que todas las promesas en el array se resuelvan. Cuando eso sucede, la variable thread_results
contiene los valores que resolvieron las promesas. Dado que los cálculos se dividieron entre cuatro trabajadores, los sumas todos obteniendo cada valor de thread_results
usando la sintaxis de notación de corchetes. Una vez sumados, devuelves el valor total a la página.
Tu archivo completo debería lucir así:
const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = process.env.PORT || 3000;
const THREAD_COUNT = 4;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function createWorker() {
return new Promise(function (resolve, reject) {
const worker = new Worker("./four_workers.js", {
workerData: { thread_count: THREAD_COUNT },
});
worker.on("message", (data) => {
resolve(data);
});
worker.on("error", (msg) => {
reject(`An error ocurred: ${msg}`);
});
});
}
app.get("/blocking", async (req, res) => {
const workerPromises = [];
for (let i = 0; i < THREAD_COUNT; i++) {
workerPromises.push(createWorker());
}
const thread_results = await Promise.all(workerPromises);
const total =
thread_results[0] +
thread_results[1] +
thread_results[2] +
thread_results[3];
res.status(200).send(`result is ${total}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Guarda y cierra tu archivo. Antes de ejecutar este archivo, primero ejecuta index.js
para medir su tiempo de respuesta.
- node index.js
A continuación, abre una nueva terminal en tu computadora local y ingresa el siguiente comando curl
, que mide cuánto tiempo tarda en recibir una respuesta desde la ruta /blocking
:
- time curl --get http://localhost:3000/blocking
El comando time
mide cuánto tiempo tarda en ejecutarse el comando curl
. El comando curl
envía una solicitud HTTP a la URL dada y la opción --get
instruye a curl
para hacer una solicitud GET
.
Cuando se ejecuta el comando, tu salida se verá similar a esto:
Outputreal 0m28.882s
user 0m0.018s
sys 0m0.000s
La salida resaltada muestra que tarda aproximadamente 28 segundos en recibir una respuesta, lo cual podría variar en tu computadora.
A continuación, detén el servidor con CTRL+C
y ejecuta el archivo index_four_workers.js
:
- node index_four_workers.js
Visita nuevamente la ruta /blocking
en tu segunda terminal:
- time curl --get http://localhost:3000/blocking
Verás una salida consistente con la siguiente:
Outputreal 0m8.491s
user 0m0.011s
sys 0m0.005s
La salida muestra que tarda unos 8 segundos, lo que significa que redujiste el tiempo de carga aproximadamente en un 70%.
Has optimizado con éxito la tarea ligada a la CPU utilizando cuatro hilos de trabajo. Si tienes una máquina con más de cuatro núcleos, actualiza el THREAD_COUNT
a ese número y reducirás aún más el tiempo de carga.
Conclusión
En este artículo, construiste una aplicación Node con una tarea vinculada a la CPU que bloquea el hilo principal. Luego intentaste hacer que la tarea fuera no bloqueante usando promesas, lo cual no tuvo éxito. Después de eso, utilizaste el módulo worker_threads
para desviar la tarea vinculada a la CPU a otro hilo para que sea no bloqueante. Finalmente, utilizaste el módulo worker_threads
para crear cuatro hilos y acelerar la tarea intensiva en la CPU.
Como próximo paso, consulta la documentación de Node.js Worker threads para obtener más información sobre las opciones. Además, puedes revisar la biblioteca piscina
, que te permite crear un grupo de trabajadores para tus tareas intensivas en la CPU. Si deseas seguir aprendiendo Node.js, consulta la serie de tutoriales, Cómo programar en Node.js.
Source:
https://www.digitalocean.com/community/tutorials/how-to-use-multithreading-in-node-js