Fermer

février 13, 2024

Une introduction au multithreading Node.js –

Une introduction au multithreading Node.js –


Les environnements d’exécution JavaScript utilisent un seul thread de traitement. Le moteur fait une chose à la fois et doit terminer son exécution avant de pouvoir faire autre chose. Cela pose rarement des problèmes dans un navigateur, car un seul utilisateur interagit avec l’application. Mais les applications Node.js pourraient traiter des centaines de demandes d’utilisateurs. Le multithreading peut éviter les goulots d’étranglement dans votre application.

Prenons l’exemple d’une application Web Node.js dans laquelle un seul utilisateur pourrait déclencher un calcul JavaScript complexe de dix secondes. L’application ne sera pas en mesure de gérer les demandes entrantes d’autres utilisateurs tant que ce calcul n’est pas terminé.

Les langages tels que PHP et Python sont également monothread, mais ils utilisent généralement un serveur Web multithread qui lance une nouvelle instance de l’interpréteur à chaque requête. Cela nécessite beaucoup de ressources, c’est pourquoi les applications Node.js fournissent souvent leur propre serveur Web léger.

Un serveur Web Node.js s’exécute sur un seul thread, mais JavaScript atténue les problèmes de performances grâce à sa boucle d’événements non bloquante. Les applications peuvent exécuter de manière asynchrone des opérations telles que des fichiers, des bases de données et HTTP qui s’exécutent sur d’autres threads du système d’exploitation. La boucle d’événements continue et peut gérer d’autres tâches JavaScript en attendant la fin des opérations d’E/S.

Malheureusement, le code JavaScript de longue durée, tel que le traitement d’images, peut monopoliser l’itération actuelle de la boucle d’événements. Cet article explique comment déplacer le traitement vers un autre thread en utilisant :

Table des matières

Threads de travail Node.js

Fils de travail sont l’équivalent Node.js de les travailleurs du Web. Le thread principal transmet les données à un autre script qui les traite (de manière asynchrone) sur un thread distinct. Le thread principal continue de s’exécuter et exécute un événement de rappel lorsque le travailleur a terminé son travail.

fils de travail

Notez que JavaScript utilise son algorithme de clonage structuré pour sérialiser les données dans une chaîne lorsqu’elles sont transmises vers et depuis un travailleur. Il peut inclure des types natifs tels que des chaînes, des nombres, des booléens, des tableaux et des objets. mais pas de fonctions. Vous ne pourrez pas transmettre d’objets complexes, tels que des connexions à une base de données, car la plupart auront des méthodes qui ne peuvent pas être clonées. Cependant, vous pourriez :

  • Lisez de manière asynchrone les données de la base de données dans le thread principal et transmettez les données résultantes au travailleur.
  • Créez un autre objet de connexion dans le travailleur. Cela aura un coût de démarrage, mais peut s’avérer pratique si votre fonction nécessite d’autres requêtes de base de données dans le cadre du calcul.

L’API du thread de travail Node.js est conceptuellement similaire à l’API API des travailleurs Web dans le navigateur, mais il existe des différences syntaxiques. Pas et Chignon prend en charge à la fois le navigateur et les API Node.js.

Démonstration du thread de travail

La démonstration suivante montre un processus Node.js qui écrit l’heure actuelle sur la console toutes les secondes : Ouvrez la démonstration Node.js dans un nouvel onglet de navigateur.

Un calcul de lancer de dés de longue durée se lance ensuite sur le thread principal. La boucle effectue 100 millions d’itérations, ce qui arrête le temps de sortie :

  timer process 12:33:18 PM
  timer process 12:33:19 PM
  timer process 12:33:20 PM
NO THREAD CALCULATION STARTED...
┌─────────┬──────────┐
│ (index) │  Values  │
├─────────┼──────────┤
│    22776134  │
│    35556674  │
│    48335819  │
│    511110893 │
│    613887045 │
│    716669114 │
│    813885068 │
│    911112704 │
│   108332503  │
│   115556106  │
│   122777940  │
└─────────┴──────────┘
processing time: 2961ms
NO THREAD CALCULATION COMPLETE

timer process 12:33:24 PM

Une fois terminé, le même calcul se lance sur un thread de travail. L’horloge continue de fonctionner pendant le traitement des dés :

WORKER CALCULATION STARTED...
  timer process 12:33:27 PM
  timer process 12:33:28 PM
  timer process 12:33:29 PM
┌─────────┬──────────┐
│ (index) │  Values  │
├─────────┼──────────┤
│    22778246  │
│    35556129  │
│    48335780  │
│    511114930 │
│    613889458 │
│    716659456 │
│    813889139 │
│    911111219 │
│   108331738  │
│   115556788  │
│   122777117  │
└─────────┴──────────┘
processing time: 2643ms
WORKER CALCULATION COMPLETE

  timer process 12:33:30 PM

Le processus de travail est un peu plus rapide que le thread principal car il peut se concentrer sur une seule tâche.

Comment utiliser les threads de travail

UN dice.js Le fichier du projet de démonstration définit une fonction de lancement de dés. Il a dépassé le nombre de courses (lancers), le nombre de dés et le nombre de côtés sur chaque dé. A chaque lancer, la fonction calcule les dés sum et incrémente le nombre de fois où il est observé dans le stat tableau. La fonction renvoie le tableau lorsque tous les lancers sont terminés :


export function diceRun(runs = 1, dice = 2, sides = 6) {
  const stat = [];

  while (runs > 0) {
    let sum = 0;

    for (let d = dice; d > 0; d--) {
      sum += Math.floor(Math.random() * sides) + 1;
    }
    stat[sum] = (stat[sum] || 0) + 1;
    runs--;
  }

  return stat;
}

Le principal index.js Le script démarre un processus de minuterie qui affiche la date et l’heure actuelles toutes les secondes :

const intlTime = new Intl.DateTimeFormat([], { timeStyle: "medium" });



timer = setInterval(() => {
  console.log(`  timer process ${ intlTime.format(new Date()) }`);
}, 1000);

Quand le principal le thread s’exécute diceRun() directement, le chronomètre s’arrête car rien d’autre ne peut s’exécuter pendant le calcul :

import { diceRun } from "./dice.js";


const
  throws = 100_000_000,
  dice = 2,
  sides = 6;


const stat = diceRun(throws, dice, sides);
console.table(stat);

Pour exécuter le calcul dans un autre thread, le code définit un nouveau Objet travailleur avec le nom de fichier du script de travail. Il passe un workerData variable – un objet avec les propriétés throws, diceet sides:

const worker = new Worker("./src/worker.js", {
  workerData: { throws, dice, sides }
});

Cela démarre le script de travail qui exécute diceRun() avec les paramètres passés workerData:


import { workerData, parentPort } from "node:worker_threads";
import { diceRun } from "./dice.js";


const stat = diceRun(workerData.throws, workerData.dice, workerData.sides);


parentPort.postMessage(stat);

Le parentPort.postMessage(stat); L’appel renvoie le résultat au thread principal. Cela soulève un "message" événement dans index.jsqui reçoit le résultat et l’affiche dans la console :


worker.on("message", result => {
  console.table(result);
});

Vous pouvez définir des gestionnaires pour d’autres événements de travail :

  • Le script principal peut utiliser worker.postMessage(data) pour envoyer des données arbitraires au travailleur à tout moment. Cela déclenche un "message" événement dans le script de travail :
    parentPort.on("message", data => {
      console.log("from main:", data);
    });
    
  • "messageerror" se déclenche dans le thread principal lorsque le travailleur reçoit des données qu’il ne peut pas désérialiser.
  • "online" se déclenche dans le thread principal lorsque le thread de travail commence à s’exécuter.
  • "error" se déclenche dans le thread principal lorsqu’une erreur JavaScript se produit dans le thread de travail. Vous pouvez l’utiliser pour mettre fin au travailleur. Par exemple:
    worker.on("error", e => {
      console.log(e);
      worker.terminate();
    });
    
  • "exit" se déclenche dans le thread principal lorsque le travailleur se termine. Cela pourrait être utilisé pour le nettoyage, la journalisation, la surveillance des performances, etc. :
    worker.on("exit", code => {
      
      console.log("worker complete");
    });
    

Threads de travail en ligne

Un seul fichier de script peut contenir les deux code principal et code de travail. Votre script doit vérifier s’il s’exécute sur le thread principal en utilisant isMainThreadpuis se présente comme un travailleur en utilisant import.meta.url comme référence de fichier dans un module ES (ou __filename dans CommonJS) :

import { Worker, isMainThread, workerData, parentPort } from "node:worker_threads";

if (isMainThread) {

  
  
  const worker = new Worker(import.meta.url, {
    workerData: { throws, dice, sides }
  });

  worker.on("message", msg => {});
  worker.on("exit", code => {});

}
else {

  
  const stat = diceRun(workerData.throws, workerData.dice, workerData.sides);
  parentPort.postMessage(stat);

}

Que cela soit pratique ou non est une autre affaire. Je vous recommande de diviser les scripts principaux et de travail, sauf s’ils utilisent des modules identiques.

Partage de données de fil de discussion

Vous pouvez partager des données entre les threads à l’aide d’un SharedArrayBuffer objet représentant des données binaires brutes de longueur fixe. Le thread principal suivant définit 100 éléments numériques de 0 à 99, qu’il envoie à un travailleur :

import { Worker } from "node:worker_threads";

const
  buffer = new SharedArrayBuffer(100 * Int32Array.BYTES_PER_ELEMENT),
  value = new Int32Array(buffer);

value.forEach((v,i) => value[i] = i);

const worker = new Worker("./worker.js");

worker.postMessage({ value });

Le travailleur peut recevoir le value objet:

import { parentPort } from 'node:worker_threads';

parentPort.on("message", value => {
  value[0] = 100;
});

À ce stade, le thread principal ou le thread de travail peuvent modifier des éléments dans le value tableau et il a changé dans les deux. Cela entraîne des gains d’efficacité car il n’y a pas de sérialisation des données, mais :

  • vous ne pouvez partager que des entiers
  • il peut être nécessaire d’envoyer des messages pour indiquer que les données ont changé
  • il y a un risque que deux threads changent la même valeur en même temps et perdent la synchronisation

Peu d’applications nécessitent un partage de données complexe, mais cela pourrait constituer une option viable dans les applications hautes performances telles que les jeux.

Processus enfants Node.js

Processus enfants lancez une autre application (pas nécessairement JavaScript), transmettez des données et recevez un résultat généralement via un rappel. Ils fonctionnent de la même manière que les Workers, mais ils sont généralement moins efficaces et plus gourmands en processus, car ils dépendent de processus extérieurs à Node.js. Il peut également y avoir des différences et des incompatibilités entre les systèmes d’exploitation.

Node.js possède trois types généraux de processus enfants avec des variantes synchrones et asynchrones :

  • spawn: génère un nouveau processus
  • exec: génère un shell et exécute une commande à l’intérieur
  • fork: génère un nouveau processus Node.js

La fonction suivante utilise spawn pour exécuter une commande de manière asynchrone en passant la commande, un tableau d’arguments et un délai d’attente. La promesse est résolue ou rejetée avec un objet contenant les propriétés complete (true ou false), un code (en général 0 pour réussir), et un result chaîne:

import { spawn } from 'node:child_process';


function execute(cmd, args = [], timeout = 600000) {

  return new Promise((resolve, reject) => {

    try {

      const
        exec = spawn(cmd, args, {
          timeout
        });

      let ret = '';

      exec.stdout.on('data', data => {
        ret += '\n' + data;
      });

      exec.stderr.on('data', data => {
        ret += '\n' + data;
      });

      exec.on('close', code => {

        resolve({
          complete: !code,
          code,
          result: ret.trim()
        });

      });

    }
    catch(err) {

      reject({
        complete: false,
        code: err.code,
        result: err.message
      });

    }

  });

}

Vous pouvez l’utiliser pour exécuter une commande du système d’exploitation, par exemple répertorier le contenu du répertoire de travail sous forme de chaîne sous macOS ou Linux :

const ls = await execute('ls', ['-la'], 1000);
console.log(ls);

Clustering Node.js

Clusters Node.js vous permettent de créer un certain nombre de processus identiques pour gérer les charges plus efficacement. Le processus principal initial peut se lancer lui-même – peut-être une fois pour chaque CPU renvoyé par os.cpus(). Il peut également gérer les redémarrages en cas d’échec d’une instance et négocier des messages de communication entre les processus forkés.

Le cluster la bibliothèque propose des propriétés et des méthodes comprenant :

  • .isPrimary ou .isMaster: Retour true pour le processus primaire principal
  • .fork(): génère un processus de travail enfant
  • .isWorker: renvoie vrai pour les processus de travail

L’exemple ci-dessous démarre un processus de travail de serveur Web pour chaque processeur/cœur de l’appareil. Une machine à 4 cœurs générera quatre instances du serveur Web, ce qui lui permettra de gérer jusqu’à quatre fois la charge. Il redémarre également tout processus qui échoue, l’application devrait donc être plus robuste :


import cluster from 'node:cluster';
import process from 'node:process';
import { cpus } from 'node:os';
import http from 'node:http';

const cpus = cpus().length;

if (cluster.isPrimary) {

  console.log(`Started primary process: ${ process.pid }`);

  
  for (let i = 0; i < cpus; i++) {
    cluster.fork();
  }

  
  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${ worker.process.pid } failed`);
    cluster.fork();
  });

}
else {

  
  http.createServer((req, res) => {

    res.writeHead(200);
    res.end('Hello!');

  }).listen(8080);

  console.log(`Started worker process:  ${ process.pid }`);

}

Tous les processus partagent le port 8080 et n’importe qui peut gérer une requête HTTP entrante. Le journal lors de l’exécution des applications affiche quelque chose comme ceci :

$ node app.js
Started primary process: 1001
Started worker process:  1002
Started worker process:  1003
Started worker process:  1004
Started worker process:  1005

...etc...

worker 1002 failed
Started worker process:  1006

Peu de développeurs tentent le clustering. L’exemple ci-dessus est simple et fonctionne bien, mais le code peut devenir de plus en plus complexe à mesure que vous essayez de gérer les échecs, les redémarrages et les messages entre les forks.

Gestionnaires de processus

Un gestionnaire de processus Node.js peut aider à exécuter plusieurs instances d’une seule application Node.js sans avoir à écrire de code de cluster. Le plus connu est PM2. La commande suivante démarre une instance de votre application pour chaque processeur/cœur et les redémarre en cas d’échec :

pm2 start ./app.js -i max

Les instances d’application démarrent en arrière-plan, elles sont donc idéales pour une utilisation sur un serveur en direct. Vous pouvez examiner quels processus sont en cours d’exécution en entrant pm2 status:

$ pm2 status

┌────┬──────┬───────────┬─────────┬─────────┬──────┬────────┐
│ id │ name │ namespace │ version │ mode    │ pid  │ uptime │
├────┼──────┼───────────┼─────────┼─────────┼──────┼────────┤
│ 1  │ app  │ default   │ 1.0.0   │ cluster │ 1001 │ 4D     │
│ 2  │ app  │ default   │ 1.0.0   │ cluster │ 1002 │ 4D     │
└────┴──────┴───────────┴─────────┴─────────┴──────┴────────┘

PM2 peut également exécuter des applications non Node.js écrites en Deno, Bun, Python, etc.

Orchestration des conteneurs

Les clusters et les gestionnaires de processus lient une application à un périphérique spécifique. Si votre serveur ou une dépendance du système d’exploitation échoue, votre application échoue quel que soit le nombre d’instances en cours d’exécution.

Les conteneurs sont un concept similaire aux machines virtuelles mais, plutôt que d’émuler du matériel, ils émulent un système d’exploitation. Un conteneur est un wrapper léger autour d’une seule application avec tous les systèmes d’exploitation, bibliothèques et fichiers exécutables nécessaires. Un seul conteneur peut contenir une instance isolée de Node.js et de votre application, de sorte qu’il s’exécute sur un seul appareil ou sur des milliers de machines.

L’orchestration des conteneurs dépasse le cadre de cet article, vous devriez donc y examiner de plus près Docker et Kubernetes.

Conclusion

Les travailleurs Node.js et les méthodes multithreading similaires améliorent les performances des applications et réduisent les goulots d’étranglement en exécutant du code en parallèle. Ils peuvent également rendre les applications plus robustes en exécutant des fonctions dangereuses dans des threads séparés et en y mettant fin lorsque les temps de traitement dépassent certaines limites.

Les travailleurs ont des frais généraux, donc certaines expérimentations peuvent être nécessaires pour garantir qu’ils améliorent les résultats. Vous n’en aurez peut-être pas besoin pour des tâches d’E/S asynchrones lourdes, et la gestion des processus/conteneurs peut offrir un moyen plus simple de faire évoluer les applications.




Source link