Comment utiliser le multithreading en Node.js

L’auteur a sélectionné Open Sourcing Mental Illness pour recevoir un don dans le cadre du programme Write for DOnations.

Introduction

Node.js exécute du code JavaScript dans un seul thread, ce qui signifie que votre code ne peut effectuer qu’une tâche à la fois. Cependant, Node.js lui-même est multithreadé et fournit des threads cachés via la bibliothèque libuv, qui gère les opérations d’E/S telles que la lecture de fichiers depuis un disque ou les requêtes réseau. Grâce à l’utilisation de threads cachés, Node.js propose des méthodes asynchrones qui permettent à votre code de faire des requêtes d’E/S sans bloquer le thread principal.

Bien que Node.js dispose de threads cachés, vous ne pouvez pas les utiliser pour décharger des tâches intensives en CPU, telles que des calculs complexes, le redimensionnement d’images ou la compression vidéo. Étant donné que JavaScript est monthreadé, lorsqu’une tâche intensive en CPU s’exécute, elle bloque le thread principal et aucun autre code ne s’exécute tant que la tâche n’est pas terminée. Sans utiliser d’autres threads, la seule façon d’accélérer une tâche liée au processeur est d’augmenter la vitesse du processeur.

Cependant, ces dernières années, les processeurs ne sont pas devenus plus rapides. Au lieu de cela, les ordinateurs sont livrés avec des cœurs supplémentaires, et il est maintenant plus courant que les ordinateurs aient 8 coeurs ou plus. Malgré cette tendance, votre code ne profitera pas des coeurs supplémentaires de votre ordinateur pour accélérer les tâches liées au processeur ou éviter de bloquer le thread principal car JavaScript est mono-thread.

Pour remédier à cela, Node.js a introduit le module worker-threads, qui vous permet de créer des threads et d’exécuter plusieurs tâches JavaScript en parallèle. Une fois qu’un thread a terminé une tâche, il envoie un message au thread principal contenant le résultat de l’opération afin qu’il puisse être utilisé avec d’autres parties du code. L’avantage d’utiliser des threads de travail est que les tâches liées au processeur ne bloquent pas le thread principal et vous pouvez diviser et distribuer une tâche à plusieurs travailleurs pour l’optimiser.

Dans ce tutoriel, vous créerez une application Node.js avec une tâche intensive en terme de processeur qui bloque le thread principal. Ensuite, vous utiliserez le module worker-threads pour décharger la tâche intensive en terme de processeur vers un autre thread afin d’éviter de bloquer le thread principal. Enfin, vous diviserez la tâche liée au processeur et vous ferez travailler quatre threads en parallèle pour accélérer la tâche.

Prérequis

Pour réaliser ce tutoriel, vous aurez besoin de:

Configuration du projet et installation des dépendances

Dans cette étape, vous créerez le répertoire du projet, initialiserez npm et installerez toutes les dépendances nécessaires.

Pour commencer, créez et déplacez-vous dans le répertoire du projet:

  1. mkdir multi-threading_demo
  2. cd multi-threading_demo

La commande mkdir crée un répertoire et la commande cd change le répertoire de travail pour le nouveau.

Ensuite, initialisez le répertoire du projet avec npm en utilisant la commande npm init:

  1. npm init -y

L’option -y accepte toutes les options par défaut.

Lorsque la commande s’exécute, votre sortie ressemblera à ceci:

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"
}

Ensuite, installez express, un framework web Node.js:

  1. npm install express

Vous utiliserez Express pour créer une application serveur avec des points d’arrêt bloquants et non bloquants.

Node.js est livré avec le module worker-threads par défaut, vous n’avez donc pas besoin de l’installer.

Vous avez maintenant installé les packages nécessaires. Ensuite, vous en apprendrez plus sur les processus et les threads et sur la façon dont ils s’exécutent sur un ordinateur.

Comprendre les processus et les threads

Avant de commencer à écrire des tâches liées au processeur et de les déléguer à des threads séparés, il est essentiel de comprendre ce que sont les processus et les threads, ainsi que les différences entre eux. Plus important encore, vous examinerez comment les processus et les threads s’exécutent sur un système informatique mono ou multi-cœur.

Processus

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.

Pour comprendre cela, vous allez créer un programme Node.js avec une boucle infinie de sorte qu’il ne se termine pas lorsqu’il est exécuté.

À l’aide de nano, ou de votre éditeur de texte préféré, créez et ouvrez le fichier process.js:

  1. nano process.js

Dans votre fichier process.js, saisissez le code suivant:

multi-threading_demo/process.js
const process_name = process.argv.slice(2)[0];

count = 0;
while (true) {
  count++;
  if (count == 2000 || count == 4000) {
    console.log(`${process_name}: ${count}`);
  }
}

Dans la première ligne, la propriété process.argv renvoie un tableau contenant les arguments de la ligne de commande du programme. Vous attachez ensuite la méthode slice() de JavaScript avec un argument de 2 pour faire une copie superficielle du tableau à partir de l’index 2. Ce faisant, vous ignorez les deux premiers arguments, qui sont le chemin Node.js et le nom du programme. Ensuite, vous utilisez la syntaxe de notation entre crochets pour récupérer le premier argument du tableau découpé et le stocker dans la variable process_name.

Ensuite, vous définissez une boucle while et lui passez une condition true pour la faire tourner indéfiniment. À l’intérieur de la boucle, la variable count est incrémentée de 1 à chaque itération. Ensuite, une instruction if vérifie si count est égal à 2000 ou 4000. Si la condition est vraie, la méthode console.log() affiche un message dans le terminal.

Enregistrez et fermez votre fichier en utilisant CTRL+X, puis appuyez sur Y pour enregistrer les modifications.

Exécutez le programme en utilisant la commande node:

  1. 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.

Lorsque vous exécutez le programme, vous verrez une sortie similaire à celle-ci-dessous:

Output
[1] 7754 A: 2000 A: 4000

Le nombre 7754 est un identifiant de processus que le système d’exploitation lui a attribué. A: 2000 et A: 4000 sont les sorties du programme.

Lorsque vous exécutez un programme en utilisant la commande node, vous créez un processus. Le système d’exploitation alloue de la mémoire pour le programme, localise l’exécutable du programme sur le disque de votre ordinateur et charge le programme en mémoire. Ensuite, il lui attribue un identifiant de processus et commence à exécuter le programme. À ce stade, votre programme est devenu un processus.

Lorsque le processus est en cours d’exécution, son identifiant de processus est ajouté à la liste des processus du système d’exploitation et peut être consulté avec des outils comme htop, top ou ps. Ces outils fournissent plus de détails sur les processus, ainsi que des options pour les arrêter ou les prioriser.

Pour obtenir un résumé rapide d’un processus Node, appuyez sur ENTER dans votre terminal pour récupérer le prompt. Ensuite, exécutez la commande ps pour voir les processus Node :

  1. ps |grep node

La commande ps liste tous les processus associés à l’utilisateur actuel sur le système. L’opérateur pipe | permet de passer la sortie de ps à la commande grep pour filtrer les processus et n’afficher que les processus Node.

L’exécution de la commande donnera une sortie similaire à celle-ci :

Output
7754 pts/0 00:21:49 node

Vous pouvez créer un nombre illimité de processus à partir d’un seul programme. Par exemple, utilisez la commande suivante pour créer trois autres processus avec des arguments différents et les exécuter en arrière-plan :

  1. node process.js B & node process.js C & node process.js D &

Dans cette commande, vous avez créé trois autres instances du programme process.js. Le symbole & place chaque processus en arrière-plan.

Après avoir exécuté la commande, la sortie ressemblera à ceci (bien que l’ordre puisse varier) :

Output
[2] 7821 [3] 7822 [4] 7823 D: 2000 D: 4000 B: 2000 B: 4000 C: 2000 C: 4000

Comme vous pouvez le voir dans la sortie, chaque processus a enregistré le nom du processus dans le terminal lorsque le compteur a atteint 2000 et 4000. Chaque processus n’a pas connaissance des autres processus en cours d’exécution : le processus D n’a pas connaissance du processus C, et vice versa. Tout ce qui se produit dans l’un des processus n’affectera pas les autres processus Node.js.

Si vous examinez attentivement la sortie, vous verrez que l’ordre de la sortie n’est pas le même que celui que vous aviez lorsque vous avez créé les trois processus. Lors de l’exécution de la commande, les arguments des processus étaient dans l’ordre de B, C et D. Mais maintenant, l’ordre est D, B et C. La raison en est que le système d’exploitation dispose d’algorithmes de planification qui décident quel processus s’exécute sur le CPU à un moment donné.

Sur une machine à un seul cœur, les processus s’exécutent de manière concurrente. C’est-à-dire que le système d’exploitation bascule entre les processus à intervalles réguliers. Par exemple, le processus D s’exécute pendant un temps limité, puis son état est enregistré quelque part et le système d’exploitation planifie l’exécution du processus B pendant un temps limité, et ainsi de suite. Cela se passe en aller-retour jusqu’à ce que toutes les tâches soient terminées. À partir de la sortie, il peut sembler que chaque processus s’est exécuté jusqu’à la fin, mais en réalité, le planificateur du système d’exploitation passe constamment de l’un à l’autre.

Sur un système multi-cœur – en supposant que vous ayez quatre cœurs – le système d’exploitation planifie l’exécution de chaque processus sur chaque cœur simultanément. C’est ce qu’on appelle le parallélisme. Cependant, si vous créez quatre autres processus (ce qui porte le total à huit), chaque cœur exécutera deux processus simultanément jusqu’à ce qu’ils soient terminés.

Les threads

Les threads sont similaires aux processus : ils ont leur propre pointeur d’instruction et peuvent exécuter une tâche JavaScript à la fois. Contrairement aux processus, les threads n’ont pas leur propre mémoire. Au lieu de cela, ils résident dans la mémoire d’un processus. Lorsque vous créez un processus, vous pouvez créer plusieurs threads avec le module worker_threads qui exécutent du code JavaScript en parallèle. De plus, les threads peuvent communiquer les uns avec les autres en échangeant des messages ou en partageant des données dans la mémoire du processus. Cela les rend légers par rapport aux processus, car la création d’un thread ne demande pas plus de mémoire au système d’exploitation.

En ce qui concerne l’exécution des threads, ils ont un comportement similaire à celui des processus. Si vous avez plusieurs threads en cours d’exécution sur un système à un seul cœur, le système d’exploitation les alternera à intervalles réguliers, donnant à chaque thread une chance d’être exécuté directement sur le seul CPU. Sur un système multicœur, le système d’exploitation planifie les threads sur tous les cœurs et exécute le code JavaScript en même temps. Si vous créez plus de threads qu’il n’y a de cœurs disponibles, chaque cœur exécutera plusieurs threads simultanément.

Avec cela, appuyez sur ENTRÉE, puis arrêtez tous les processus Node en cours d’exécution avec la commande kill:

  1. sudo kill -9 `pgrep node`

pgrep renvoie les ID de processus des quatre processus Node à la commande kill. L’option -9 indique à kill d’envoyer un signal SIGKILL.

Lorsque vous exécutez la commande, vous verrez une sortie similaire à celle-ci :

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

Parfois, la sortie peut être retardée et apparaître lorsque vous exécutez une autre commande ultérieurement.

Maintenant que vous connaissez la différence entre un processus et un thread, vous travaillerez avec les threads cachés de Node.js dans la prochaine section.

Comprendre les threads cachés dans Node.js

Node.js fournit des threads supplémentaires, c’est pourquoi il est considéré comme multithreadé. Dans cette section, vous examinerez les threads cachés dans Node.js, qui aident à rendre les opérations d’E/S non bloquantes.

Comme mentionné dans l’introduction, JavaScript est monothreadé et tout le code JavaScript s’exécute dans un seul thread. Cela inclut le code source de votre programme et les bibliothèques tierces que vous incluez dans votre programme. Lorsqu’un programme effectue une opération d’E/S pour lire un fichier ou une requête réseau, cela bloque le thread principal.

Cependant, Node.js implémente la bibliothèque libuv, qui fournit quatre threads supplémentaires à un processus Node.js. Avec ces threads, les opérations d’E/S sont gérées séparément et lorsque celles-ci sont terminées, la boucle d’événements ajoute le rappel associé à la tâche d’E/S dans une file de microtâches. Lorsque la pile d’appels dans le thread principal est vide, le rappel est ajouté à la pile d’appels et s’exécute ensuite. Pour clarifier les choses, le rappel associé à la tâche d’E/S donnée n’est pas exécuté en parallèle ; cependant, la tâche elle-même de lecture d’un fichier ou d’une requête réseau se déroule en parallèle grâce aux threads. Une fois la tâche d’E/S terminée, le rappel s’exécute dans le thread principal.

En plus de ces quatre threads, le moteur V8 fournit également deux threads pour gérer des choses comme la collecte automatique des déchets. Cela porte donc le nombre total de threads dans un processus à sept : un thread principal, quatre threads Node.js et deux threads V8.

Pour confirmer que chaque processus Node.js dispose de sept threads, exécutez à nouveau le fichier process.js et mettez-le en arrière-plan :

  1. node process.js A &

Le terminal affichera l’ID du processus, ainsi que la sortie du programme :

Output
[1] 9933 A: 2000 A: 4000

Notez quelque part l’ID du processus et appuyez sur ENTER pour pouvoir utiliser à nouveau l’invite de commande.

Pour voir les threads, exécutez la commande top en lui passant l’ID du processus affiché dans la sortie :

  1. top -H -p 9933

-H indique à top d’afficher les threads d’un processus. Le drapeau -p indique à top de surveiller uniquement l’activité dans le processus dont l’ID est spécifié.

Lorsque vous exécutez la commande, votre sortie ressemblera à ce qui suit :

Output
top - 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

Comme vous pouvez le voir dans la sortie, le processus Node.js a sept threads au total : un thread principal pour l’exécution de JavaScript, quatre threads Node.js et deux threads V8.

Comme discuté précédemment, les quatre threads Node.js sont utilisés pour les opérations d’E/S afin de les rendre non bloquantes. Ils fonctionnent bien pour cette tâche, et créer vous-même des threads pour les opérations d’E/S pourrait même détériorer les performances de votre application. Il en va autrement pour les tâches liées au processeur. Une tâche liée au processeur n’utilise aucun thread supplémentaire disponible dans le processus et bloque le thread principal.

Maintenant, appuyez sur q pour quitter top et arrêtez le processus Node avec la commande suivante :

  1. kill -9 9933

Maintenant que vous connaissez les threads dans un processus Node.js, vous allez écrire une tâche liée au processeur dans la section suivante et observer comment elle affecte le thread principal.

Création d’une tâche liée au processeur sans threads de travail

Dans cette section, vous allez créer une application Express qui a une route non bloquante et une route bloquante qui exécute une tâche liée au processeur.

Tout d’abord, ouvrez index.js dans votre éditeur préféré :

  1. nano index.js

Dans votre fichier index.js, ajoutez le code suivant pour créer un serveur de base :

multi-threading_demo/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");
});

app.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Dans le bloc de code suivant, vous créez un serveur HTTP en utilisant Express. Dans la première ligne, vous importez le module express. Ensuite, vous définissez la variable app pour contenir une instance d’Express. Ensuite, vous définissez la variable port, qui contient le numéro de port sur lequel le serveur doit écouter.

Ensuite, vous utilisez app.get('/non-blocking') pour définir la route à laquelle les requêtes GET doivent être envoyées. Enfin, vous appelez la méthode app.listen() pour indiquer au serveur de commencer à écouter sur le port 3000.

Ensuite, définissez une autre route, /blocking/, qui contiendra une tâche intensive pour le processeur:

multi-threading_demo/index.js
...
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}`);
});

Vous définissez la route /blocking en utilisant app.get("/blocking"), qui prend un rappel asynchrone précédé du mot-clé async en tant que deuxième argument qui exécute une tâche intensive pour le processeur. Dans le rappel, vous créez une boucle for qui itère 20 milliards de fois et à chaque itération, elle incrémente la variable counter de 1. Cette tâche s’exécute sur le processeur et prendra quelques secondes pour se terminer.

À ce stade, votre fichier index.js ressemblera à ceci:

multi-threading_demo/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");
});

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}`);
});

Enregistrez et quittez votre fichier, puis démarrez le serveur avec la commande suivante:

  1. node index.js

Lorsque vous exécutez la commande, vous verrez une sortie similaire à celle-ci:

Output
App listening on port 3000

Cela montre que le serveur est en cours d’exécution et prêt à servir.

Maintenant, rendez-vous sur http://localhost:3000/non-blocking dans votre navigateur préféré. Vous verrez une réponse instantanée avec le message Cette page est non bloquante.

Note: Si vous suivez le tutoriel sur un serveur distant, vous pouvez utiliser le transfert de port pour tester l’application dans le navigateur.

Tout en laissant le serveur Express en cours d’exécution, ouvrez un autre terminal sur votre ordinateur local et saisissez la commande suivante :

  1. ssh -L 3000:localhost:3000 your-non-root-user@yourserver-ip

Après vous être connecté au serveur, accédez à l’adresse http://localhost:3000/non-blocking dans le navigateur web de votre machine locale. Gardez le deuxième terminal ouvert pendant le reste de ce tutoriel.

Ensuite, ouvrez un nouvel onglet et visitez http://localhost:3000/blocking. Pendant que la page se charge, ouvrez rapidement deux autres onglets et visitez à nouveau http://localhost:3000/non-blocking. Vous verrez que vous n’obtiendrez pas de réponse instantanée et que les pages continueront à essayer de se charger. Ce n’est qu’après que la route /blocking ait fini de se charger et renvoie une réponse result is 20000000000 que le reste des routes renverra une réponse.

La raison pour laquelle toutes les routes /non-blocking ne fonctionnent pas pendant le chargement de la route /blocking est due à la boucle for liée au processeur, qui bloque le thread principal. Lorsque le thread principal est bloqué, Node.js ne peut pas traiter les requêtes tant que la tâche liée au processeur n’est pas terminée. Ainsi, si votre application reçoit des milliers de requêtes GET simultanées vers la route /non-blocking, une seule visite à la route /blocking suffit à rendre toutes les routes de l’application non réactives.

Comme vous pouvez le voir, bloquer le thread principal peut nuire à l’expérience de l’utilisateur avec votre application. Pour résoudre ce problème, vous devrez décharger la tâche liée au processeur vers un autre thread afin que le thread principal puisse continuer à traiter d’autres requêtes HTTP.

À cet effet, arrêtez le serveur en appuyant sur CTRL+C. Vous redémarrerez le serveur dans la prochaine section après avoir apporté d’autres modifications au fichier index.js. La raison pour laquelle le serveur est arrêté est que Node.js ne se rafraîchit pas automatiquement lorsque de nouvelles modifications sont apportées au fichier.

Maintenant que vous comprenez l’impact négatif qu’une tâche intensive en CPU peut avoir sur votre application, vous allez essayer d’éviter de bloquer le thread principal en utilisant des promesses.

Déchargement d’une tâche liée au processeur en utilisant des promesses

Souvent, lorsque les développeurs prennent conscience de l’effet bloquant des tâches liées au processeur, ils se tournent vers les promesses pour rendre le code non bloquant. Cette intuition découle de la connaissance de l’utilisation de méthodes d’E/S basées sur des promesses non bloquantes, telles que readFile() et writeFile(). Mais comme vous l’avez appris, les opérations d’E/S utilisent des threads cachés de Node.js, ce que ne font pas les tâches liées au processeur. Néanmoins, dans cette section, vous allez envelopper la tâche liée au processeur dans une promesse dans le but de la rendre non bloquante. Cela ne fonctionnera pas, mais cela vous aidera à comprendre l’intérêt d’utiliser des threads de travail, ce que vous ferez dans la section suivante.

Ouvrez à nouveau le fichier index.js dans votre éditeur :

  1. nano index.js

Dans votre fichier index.js, supprimez le code surligné contenant la tâche intensive en CPU :

multi-threading_demo/index.js
...
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}`);
});
...

Ensuite, ajoutez le code surligné suivant contenant une fonction qui renvoie une promesse :

multi-threading_demo/index.js
...
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 fonction calculateCount() contient maintenant les calculs que vous aviez dans la fonction de gestionnaire /blocking. La fonction renvoie une promesse, qui est initialisée avec la syntaxe new Promise. La promesse prend un rappel avec les paramètres resolve et reject, qui gèrent le succès ou l’échec. Lorsque la boucle for a terminé son exécution, la promesse se résout avec la valeur de la variable counter.

Ensuite, appelez la fonction calculateCount() dans la fonction de gestionnaire /blocking/ du fichier index.js :

multi-threading_demo/index.js
app.get("/blocking", async (req, res) => {
  const counter = await calculateCount();
  res.status(200).send(`result is ${counter}`);
});

Ici, vous appelez la fonction calculateCount() avec le mot-clé await préfixé pour attendre que la promesse se résolve. Une fois que la promesse est résolue, la variable counter est définie sur la valeur résolue.

Votre code complet ressemblera maintenant à ceci :

multi-threading_demo/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}`);
});

Enregistrez et quittez votre fichier, puis redémarrez le serveur :

  1. node index.js

Dans votre navigateur web, visitez http://localhost:3000/blocking et pendant que la page se charge, rechargez rapidement les onglets http://localhost:3000/non-blocking. Comme vous le remarquerez, les routes non-blocking sont toujours affectées et elles attendront toutes que la route /blocking termine son chargement. Étant donné que les routes sont encore affectées, les promesses ne permettent pas l’exécution en parallèle du code JavaScript et ne peuvent pas être utilisées pour rendre les tâches liées au processeur non bloquantes.

Avec cela, arrêtez le serveur d’application avec CTRL+C.

Maintenant que vous savez que les promesses ne fournissent aucun mécanisme pour rendre les tâches liées au processeur non bloquantes, vous utiliserez le module worker-threads de Node.js pour décharger une tâche liée au processeur dans un thread séparé.

Déchargement d’une tâche liée au processeur avec le module worker-threads

Dans cette section, vous déchargerez une tâche intensive pour le processeur vers un autre thread en utilisant le module worker-threads afin d’éviter de bloquer le thread principal. Pour cela, vous créerez un fichier worker.js qui contiendra la tâche intensive pour le processeur. Dans le fichier index.js, vous utiliserez le module worker-threads pour initialiser le thread et démarrer la tâche dans le fichier worker.js afin qu’elle s’exécute en parallèle du thread principal. Une fois que la tâche est terminée, le thread ouvrier enverra un message contenant le résultat au thread principal.

Pour commencer, vérifiez que vous disposez de 2 cœurs ou plus en utilisant la commande nproc:

  1. nproc
Output
4

Si cela affiche deux cœurs ou plus, vous pouvez passer à cette étape.

Ensuite, créez et ouvrez le fichier worker.js dans votre éditeur de texte:

  1. nano worker.js

Dans votre fichier worker.js, ajoutez le code suivant pour importer le module worker-threads et effectuer la tâche intensive pour le processeur:

multi-threading_demo/worker.js
const { parentPort } = require("worker_threads");

let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
  counter++;
}

La première ligne charge le module worker_threads et extrait la classe parentPort. La classe fournit des méthodes que vous pouvez utiliser pour envoyer des messages au thread principal. Ensuite, vous avez la tâche intensive en CPU qui se trouve actuellement dans la fonction calculateCount() dans le fichier index.js. Plus tard dans cette étape, vous supprimerez cette fonction de index.js.

Ensuite, ajoutez le code surligné ci-dessous:

multi-threading_demo/worker.js
const { parentPort } = require("worker_threads");

let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
  counter++;
}

parentPort.postMessage(counter);

Ici, vous appelez la méthode postMessage() de la classe parentPort, qui envoie un message au thread principal contenant le résultat de la tâche liée au CPU stockée dans la variable counter.

Enregistrez et fermez votre fichier. Ouvrez index.js dans votre éditeur de texte:

  1. nano index.js

Puisque vous avez déjà la tâche liée au CPU dans worker.js, supprimez le code surligné de index.js:

multi-threading_demo/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}`);
});

Ensuite, dans le rappel app.get("/blocking"), ajoutez le code suivant pour initialiser le thread:

multi-threading_demo/index.js
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}`);
  });
});
...

Tout d’abord, vous importez le module worker_threads et décompressez la classe Worker. Dans le rappel app.get("/blocking"), vous créez une instance de Worker en utilisant le mot-clé new suivi d’un appel à Worker avec le chemin du fichier worker.js comme argument. Cela crée un nouveau thread et le code dans le fichier worker.js commence à s’exécuter dans le thread sur un autre cœur.

Ensuite, vous attachez un événement à l’instance worker en utilisant la méthode on("message") pour écouter l’événement de message. Lorsque le message est reçu, contenant le résultat du fichier worker.js, il est transmis en tant que paramètre au rappel de la méthode, qui renvoie une réponse à l’utilisateur contenant le résultat de la tâche intensive en CPU.

Ensuite, vous attachez un autre événement à l’instance du worker en utilisant la méthode on("error") pour écouter l’événement d’erreur. Si une erreur se produit, le rappel renvoie une réponse 404 contenant le message d’erreur à l’utilisateur.

Votre fichier complet ressemblera maintenant à ceci :

multi-threading_demo/index.js
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}`);
});

Enregistrez et quittez votre fichier, puis exécutez le serveur :

  1. node index.js

Visitez à nouveau l’onglet http://localhost:3000/blocking dans votre navigateur web. Avant qu’il ne finisse de charger, rafraîchissez tous les onglets http://localhost:3000/non-blocking. Vous remarquerez maintenant qu’ils se chargent instantanément sans attendre que la route /blocking ait fini de se charger. Cela est dû au fait que la tâche intensive en CPU est déléguée à un autre thread, et le thread principal gère toutes les demandes entrantes.

Maintenant, arrêtez votre serveur en utilisant CTRL+C.

Maintenant que vous savez comment rendre une tâche intensive en CPU non bloquante en utilisant un thread de travail, vous utiliserez quatre threads de travail pour améliorer les performances de la tâche intensive en CPU.

Optimisation d’une tâche intensive en CPU en utilisant quatre threads de travail

Dans cette section, vous allez diviser la tâche intensive en CPU entre quatre threads de travail afin qu’ils puissent terminer la tâche plus rapidement et raccourcir le temps de chargement de la route /blocking.

Pour que plusieurs threads de travail travaillent sur la même tâche, vous devrez diviser les tâches. Étant donné que la tâche nécessite une boucle de 20 milliards d’itérations, vous diviserez 20 milliards par le nombre de threads que vous souhaitez utiliser. Dans ce cas, il s’agit de 4. Le calcul de 20_000_000_000 / 4 donnera 5_000_000_000. Ainsi, chaque thread effectuera une boucle de 0 à 5_000_000_000 et incrémentera counter de 1. Lorsque chaque thread a terminé, il enverra un message au thread principal contenant le résultat. Une fois que le thread principal a reçu les messages de tous les quatre threads séparément, vous combinerez les résultats et enverrez une réponse à l’utilisateur.

Vous pouvez également utiliser la même approche si vous avez une tâche qui itère sur de grands tableaux. Par exemple, si vous souhaitez redimensionner 800 images dans un répertoire, vous pouvez créer un tableau contenant tous les chemins d’accès des fichiers image. Ensuite, divisez 800 par 4 (le nombre de threads) et faites en sorte que chaque thread travaille sur une plage. Le thread un redimensionnera les images de l’indice du tableau 0 à 199, le thread deux de l’indice 200 à 399, et ainsi de suite.

Tout d’abord, vérifiez que vous avez quatre cœurs ou plus :

  1. nproc
Output
4

Faites une copie du fichier worker.js en utilisant la commande cp :

  1. cp worker.js four_workers.js

Les fichiers actuels index.js et worker.js resteront intacts afin que vous puissiez les exécuter à nouveau pour comparer leurs performances avec les modifications apportées dans cette section ultérieurement.

Ensuite, ouvrez le fichier four_workers.js dans votre éditeur de texte :

  1. nano four_workers.js

Dans votre fichier four_workers.js, ajoutez le code surligné pour importer l’objet workerData :

multi-threading_demo/four_workers.js
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);

Tout d’abord, vous extrayez l’objet WorkerData, qui contiendra les données transmises depuis le thread principal lors de l’initialisation du thread (ce que vous ferez bientôt dans le fichier index.js). L’objet a une propriété thread_count qui contient le nombre de threads, qui est 4. Ensuite, dans la boucle for, la valeur 20_000_000_000 est divisée par 4, ce qui donne 5_000_000_000.

Enregistrez et fermez votre fichier, puis copiez le fichier index.js :

  1. cp index.js index_four_workers.js

Ouvrez le fichier index_four_workers.js dans votre éditeur de texte :

  1. nano index_four_workers.js

Dans votre fichier index_four_workers.js, ajoutez le code surligné pour créer une instance de thread :

multi-threading_demo/index_four_workers.js
...
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) => {
  ...
})
...

Tout d’abord, vous définissez la constante THREAD_COUNT contenant le nombre de threads que vous souhaitez créer. Plus tard, lorsque vous aurez plus de cœurs sur votre serveur, l’évolutivité consistera à modifier la valeur de THREAD_COUNT pour le nombre de threads que vous souhaitez utiliser.

Ensuite, la fonction createWorker() crée et retourne une promesse. Dans le rappel de la promesse, vous initialisez un nouveau thread en passant la classe Worker le chemin du fichier four_workers.js en premier argument. Ensuite, vous passez un objet en tant que deuxième argument. Ensuite, vous attribuez à l’objet la propriété workerData qui a un autre objet comme valeur. Enfin, vous attribuez à l’objet la propriété thread_count dont la valeur est le nombre de threads dans la constante THREAD_COUNT. L’objet workerData est celui que vous avez référencé dans le fichier workers.js précédemment.

Pour vous assurer que la promesse se résout ou lance une erreur, ajoutez les lignes suivantes en surbrillance:

multi-threading_demo/index_four_workers.js
...
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}`);
    });
  });
}
...

Lorsque le thread de travail envoie un message au thread principal, la promesse se résout avec les données renvoyées. Cependant, si une erreur se produit, la promesse renvoie un message d’erreur.

Maintenant que vous avez défini la fonction qui initialise un nouveau thread et renvoie les données du thread, vous utiliserez la fonction dans app.get("/blocking") pour créer de nouveaux threads.

Mais d’abord, supprimez le code en surbrillance suivant, car vous avez déjà défini cette fonctionnalité dans la fonction createWorker():

multi-threading_demo/index_four_workers.js
...
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}`);
  });
});
...

Avec le code supprimé, ajoutez le code suivant pour initialiser quatre threads de travail:

multi-threading_demo/index_four_workers.js
...
app.get("/blocking", async (req, res) => {
  const workerPromises = [];
  for (let i = 0; i < THREAD_COUNT; i++) {
    workerPromises.push(createWorker());
  }
});
...

Tout d’abord, vous créez une variable workerPromises, qui contient un tableau vide. Ensuite, vous itérez autant de fois que la valeur de THREAD_COUNT, qui est 4. Pendant chaque itération, vous appelez la fonction createWorker() pour créer un nouveau thread. Ensuite, vous ajoutez l’objet promesse que la fonction retourne dans le tableau workerPromises en utilisant la méthode push de JavaScript. Lorsque la boucle se termine, le tableau workerPromises contient quatre objets promesse, chacun retourné par l’appel de la fonction createWorker() quatre fois.

Maintenant, ajoutez le code suivant surligné ci-dessous pour attendre que les promesses se résolvent et renvoyer une réponse à l’utilisateur :

multi-threading_demo/index_four_workers.js
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}`);
});

Puisque le tableau workerPromises contient des promesses retournées par l’appel de createWorker(), vous préfixez la méthode Promise.all() avec la syntaxe await et appelez la méthode all() avec workerPromises comme argument. La méthode Promise.all() attend que toutes les promesses du tableau se résolvent. Lorsque cela se produit, la variable thread_results contient les valeurs auxquelles les promesses se sont résolues. Étant donné que les calculs ont été répartis entre quatre travailleurs, vous les ajoutez tous ensemble en récupérant chaque valeur de thread_results en utilisant la syntaxe de notation entre crochets. Une fois ajoutées, vous renvoyez la valeur totale à la page.

Votre fichier complet devrait maintenant ressembler à ceci :

multi-threading_demo/index_four_workers.js
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}`);
});

Enregistrez et fermez votre fichier. Avant d’exécuter ce fichier, exécutez d’abord index.js pour mesurer son temps de réponse.

  1. node index.js

Ensuite, ouvrez un nouveau terminal sur votre ordinateur local et saisissez la commande curl suivante, qui mesure le temps nécessaire pour obtenir une réponse de la route /blocking :

  1. time curl --get http://localhost:3000/blocking

La commande time mesure la durée d’exécution de la commande curl. La commande curl envoie une requête HTTP à l’URL donnée et l’option --get indique à curl de faire une requête GET.

Lorsque la commande est exécutée, votre sortie ressemblera à ceci :

Output
real 0m28.882s user 0m0.018s sys 0m0.000s

La sortie mise en évidence montre qu’il faut environ 28 secondes pour obtenir une réponse, ce qui peut varier sur votre ordinateur.

Ensuite, arrêtez le serveur avec CTRL+C et exécutez le fichier index_four_workers.js :

  1. node index_four_workers.js

Visitez à nouveau la route /blocking dans votre deuxième terminal :

  1. time curl --get http://localhost:3000/blocking

Vous verrez une sortie similaire à celle-ci :

Output
real 0m8.491s user 0m0.011s sys 0m0.005s

La sortie montre qu’il faut environ 8 secondes, ce qui signifie que vous avez réduit le temps de chargement d’environ 70%.

Vous avez réussi à optimiser la tâche liée au processeur en utilisant quatre threads de travail. Si vous disposez d’une machine avec plus de quatre cœurs, mettez à jour la valeur de THREAD_COUNT avec ce nombre et vous réduirez encore davantage le temps de chargement.

Conclusion

Dans cet article, vous avez construit une application Node avec une tâche liée au processeur qui bloque le thread principal. Ensuite, vous avez essayé de rendre la tâche non bloquante en utilisant des promesses, ce qui n’a pas été fructueux. Après cela, vous avez utilisé le module worker_threads pour décharger la tâche liée au processeur vers un autre thread afin de la rendre non bloquante. Enfin, vous avez utilisé le module worker_threads pour créer quatre threads afin d’accélérer la tâche intensive en CPU.

Comme prochaine étape, consultez la documentation sur les threads de travail Node.js pour en savoir plus sur les options. De plus, vous pouvez consulter la bibliothèque piscina, qui vous permet de créer un pool de travailleurs pour vos tâches intensives en CPU. Si vous souhaitez continuer à apprendre Node.js, consultez la série de tutoriels, Comment Coder en Node.js.

Source:
https://www.digitalocean.com/community/tutorials/how-to-use-multithreading-in-node-js