Skip to main content

Le multithreading dans Node.js avec les threads worker : tutoriel sur worker_threads

Écrit par
Headshot of James Walker

James Walker

blog feature multithreading

25 octobre 2022

0 minutes de lecture

Node.js fournit un environnement d’exécution JavaScript à thread unique, qui empêche le code d’exécuter plusieurs opérations en parallèle. Si votre application repose généralement sur une exécution synchrone, les opérations de longue durée peuvent la bloquer.

Node.js est toutefois lui-même une application multithread. Cela se voit lorsque vous utilisez l’une des méthodes asynchrones de la bibliothèque standard pour effectuer des opérations d’E/S, comme la lecture d’un fichier ou l’envoi d’une requête réseau. Ces tâches sont confiées à un pool de threads distinct que Node crée et gère à l’aide de la bibliothèque C libuv. Même si cela ressemble à du multithreading, les fonctions asynchrones peuvent toujours bloquer la boucle d’événements principale.

Mais que faire si vous souhaitez créer vos propres threads indépendants pour votre application Node.js ? Le multithreading peut améliorer considérablement les performances des tâches qui sollicitent le processeur en permettant d’exécuter des opérations arbitraires en parallèle. Bien que Node.js ne propose pas de véritable multithreading, le module worker_threads permet d’en créer une forme similaire. Cet article explique son fonctionnement et montre comment l’utiliser dans quelques applications concrètes.

Que sont les threads worker ?

Le module worker_threads met en œuvre une forme de multithreading qui vous permet d’ajouter du parallélisme à votre application. Le code exécuté dans un thread worker s’exécute dans un processus enfant distinct, ce qui l’empêche de bloquer votre application principale.

Les threads worker ne sont pas de véritables threads au sens traditionnel du terme. Ce sont des processus distincts, ce qui signifie qu’ils ne peuvent pas accéder directement au contexte d’exécution de leur processus parent. La communication entre les threads worker et votre application repose sur un système de messagerie événementiel.

Même si les threads worker ne transforment pas Node.js en langage véritablement multithread, cette distinction reste théorique dans de nombreux cas concrets. Ils offrent un mécanisme pratique pour exécuter plusieurs « threads » simultanément et déléguer les tâches lourdes en dehors de la boucle principale.

Cas d’usage des threads worker

Les threads worker peuvent être utilisés pour toute opération coûteuse qui sollicite le processeur. Ils ne conviennent pas à l’accélération des opérations d’E/S, en raison de la surcharge associée à chaque thread. Les utilitaires d’E/S asynchrones intégrés à Node.js seront plus rapides et plus efficaces pour les tâches liées au système de fichiers et au réseau.

Les threads worker peuvent être utiles dans de nombreuses situations. Voici quelques cas d’usage particulièrement courants où ils peuvent vous aider :

  • Redimensionnement d’images : le redimensionnement de grandes images peut prendre plusieurs secondes, et les délais s’accumulent rapidement si vous devez générer plusieurs tailles. C’est courant dans les applications qui convertissent les photos importées en miniatures, ainsi qu’en formats petit et grand. Vous pouvez utiliser trois threads worker pour générer toutes les tailles en même temps et réduire ainsi la durée totale du traitement.

  • Compression vidéo : la compression vidéo est l’une des tâches de calcul les plus exigeantes. Les threads worker peuvent l’accélérer en traitant plusieurs images en parallèle, puis en renvoyant les résultats au thread principal.

  • Chiffrement de fichiers et autres opérations cryptographiques : les opérations cryptographiques sont volontairement complexes. Le chiffrement et le déchiffrement de fichiers, la génération de clés secrètes et la vérification de signatures peuvent toutes entraîner des ralentissements perceptibles dans un programme lorsqu’elles sont exécutées sur le thread principal.

  • Tri et recherche dans de grandes quantités de données : le filtrage et le tri des données nécessitent de nombreuses itérations pour comparer chaque valeur. Les threads worker peuvent accélérer ces opérations en examinant plusieurs ensembles de données en parallèle.

  • Opérations mathématiques complexes : les calculs mathématiques — comme la génération de nombres premiers, la factorisation de grands nombres et l’analyse complexe de données — sollicitent fortement le processeur. Effectuer une partie du travail dans un thread distinct permet à la boucle principale de traiter d’autres tâches.

Dans tous ces cas, le ralentissement s’explique par le temps que le processeur consacre à l’exécution du code, plutôt qu’à la lecture de données sur le disque ou le réseau. Il s’agit de tâches itératives : augmenter le nombre de passages effectués en parallèle est donc le meilleur moyen d’améliorer les performances. Les threads worker permettent d’y parvenir.

Utiliser le module worker threads

Pour créer des threads worker, importez le module worker_threads de Node et appelez le constructeur new Worker(). Un nouveau processus est lancé pour exécuter le fichier JavaScript que vous indiquez. Vous pouvez échanger des messages avec le worker afin d’être averti lorsqu’un événement se produit, que des données sont prêtes à être traitées ou qu’une erreur survient. Si vous avez déjà utilisé les Web Workers dans un navigateur, le concept de thread worker vous sera familier.

Voici l’exemple le plus simple d’exécution d’un fichier JavaScript dans un thread worker :

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

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

Copiez cet extrait de code et enregistrez-le dans un fichier nommé worker-demo.js dans votre répertoire de travail. Lorsque vous exécutez le code avec Node.js, le résultat suivant devrait s’afficher :

$ node worker-demo.js
Worker message received: You said "hello".
Worker exited with code 0.

Cet exemple simple présente les principes fondamentaux des threads worker. Le code source peut servir aussi bien au thread principal qu’à un thread worker. Le module worker_threads fournit un export isMainThread qui vous permet de vérifier si le code s’exécute sur le thread principal.

Lorsque le fichier s’exécute sur le thread principal — autrement dit, lorsque vous le lancez depuis votre terminal —, la première branche de l’instruction if s’exécute. Elle crée une nouvelle instance de Worker.

Le premier paramètre transmis au constructeur Worker est le chemin d’accès au fichier JavaScript à exécuter dans le worker. Dans cet exemple, vous pouvez utiliser la variable globale __filename, car le même fichier contient le code du thread principal et celui du thread worker. Le deuxième paramètre du thread Worker est un objet d’options. Sa propriété workerData vous permet de transmettre des valeurs au thread worker.

Le reste du code de la section du thread principal configure des écouteurs d’événements sur l’instance de Worker. Ils vous permettent de réagir aux messages, aux erreurs et à la fin du processus dans le worker.

Lorsque l’exemple de code est exécuté par un thread worker, la variable isMainThread vaut false et la branche else s’exécute. Elle utilise l’export workerData du module worker_threads pour accéder aux données transmises par le thread principal. L’export parentPort fournit une interface avec le thread principal, tandis que la fonction postMessage() permet de lui renvoyer des données. Celles-ci sont alors signalées sous la forme d’un événement message.

L’exécution de worker-demo.js entraîne donc la séquence d’actions suivante :

  • Un nouveau worker est créé et reçoit hello comme donnée.

  • Le code exécuté dans le worker accède à ses données et renvoie un nouveau message amical au thread principal.

  • L’écouteur d’événements du thread principal récupère le message envoyé par le thread worker et l’affiche dans la console.

  • Le thread worker n’a plus de code à exécuter et se termine. Le thread principal en est informé par l’événement exit.

Vous pouvez maintenant essayer quelques exemples concrets de threads worker.

Redimensionner des images avec des threads worker

Ce code convertit une image en trois tailles différentes en parallèle. C’est beaucoup plus rapide que de traiter chaque taille l’une après l’autre.

Commencez par créer le code qui redimensionnera l’image. Enregistrez-le dans resize-worker.js :

const {parentPort, workerData} = require("worker_threads");
const sharp = require("sharp");

const {src, width, height} = workerData;
const [filename, ext] = src.split(".");

console.log(`Resizing ${src} to ${width}px wide`);

const resize = async () => {
await sharp(src)
.resize(width, height, {fit: "cover"})
.toFile(`${src}-${width}.${ext}`);
};

resize();

Créez le code du thread principal dans un nouveau fichier nommé resize-main.js :

const {Worker} = require("worker_threads");

const src = process.argv[2];

const sizes = [
{width: 1920},
{width: 1280},
{width: 640}
];

for (const size of sizes) {
const worker = new Worker(
    __dirname + "/resize-worker.js",
    {
        workerData: {
            src,
            ...size
        }
    }
);
}

Utilisez npm pour installer le module sharp, qui fournit la fonction de redimensionnement d’images :

$ npm install sharp

Placez ensuite une grande image dans votre répertoire de travail et nommez-la image.jpg. Vous pouvez utiliser cette photo colorée de fils sur Unsplash. Exécutez votre code avec la commande suivante :

$ node resize-main.js image.jpg

Les trois messages « Resizing » devraient s’afficher instantanément. Le thread principal parcourt les tailles demandées, crée un nouveau thread pour chacune et attend qu’ils aient terminé. Les différentes tailles sont ainsi générées en parallèle, sans bloquer le thread principal.

Redimensionner des vidéos avec des threads worker

Cet extrait de code montre comment redimensionner une vidéo dans un thread worker avec [FFmpeg. Le thread principal reste ainsi libre d’effectuer d’autres tâches pendant le redimensionnement, qui sollicite fortement le processeur.

Enregistrez le code du worker dans un nouveau fichier nommé video-worker.js :

const {parentPort} = require("worker_threads");
const ffmpeg = require("fluent-ffmpeg");

const resizeVideo = (src, size) => {
    const [filename, ext] = src.split(".");
    const output = `${__dirname}/${filename}-${size}.${ext}`;
    ffmpeg(`${__dirname}/${src}`)
    .size(size)
    .on("end", () => parentPort.postMessage({output, input: src, type: "done"}))
    .save(output);
};

parentPort.on("message", msg => {
    const {file, size} = msg;
    const [filename, ext] = file.split(".")[0];
    resizeVideo(file, size);
});

Enregistrez maintenant le code du thread principal dans un nouveau fichier nommé video-main.js :

const {StaticPool} = require("node-worker-threads-pool");

const pool = new StaticPool({
    size: 4,
    task: __dirname + "/video-worker.js"
});

const videoToResize = process.argv[2];
const videoTargetSize = process.argv[3];

const resize = async () => {
    await pool.exec({file: videoToResize, size: videoTargetSize}).then(() => {
    if (msg?.type === "done") {
    console.log(`Saved ${videoToResize} to ${msg.output}`);
    }
    });
};

resize();

Installez les dépendances npm nécessaires au code :

$ npm install fluent-ffmpeg node-worker-threads-pool

Cet exemple utilise l’encodeur populaire ffmpeg. Le package fluent-ffmpeg est un wrapper Node.js qui s’appuie sur les bibliothèques ffmpeg déjà installées sur votre système. Vous devez [installer ffmpeg avant de pouvoir exécuter votre code. La plupart des environnements Linux l’incluent déjà, mais les utilisateurs de Windows et de Mac devront peut-être l’installer manuellement.

Pour vérifier si ffmpeg est disponible, exécutez la commande suivante :

$ ffmpeg
ffmpeg version 4.2.7-0ubuntu0.1 Copyright (c) 2000-2022 the FFmpeg developers

Si le numéro de version s’affiche, ffmpeg est déjà installé. Si un message du type « command ‘ffmpeg’ not found » apparaît, suivez les instructions sur le site Web de ffmpeg pour télécharger la bibliothèque adaptée à votre système. Ffmpeg est également disponible dans les dépôts de paquets de la plupart des distributions Linux populaires, ainsi que dans Homebrew pour macOS :

$ brew install ffmpeg

Vous pouvez maintenant essayer de redimensionner une vidéo en arrière-plan. Si vous n’avez pas de fichier à votre disposition, vous pouvez télécharger ce clip sur Pexels. Enregistrez-le sous le nom video.mp4 dans votre répertoire de travail.

Exécutez ensuite votre script pour réduire la résolution du clip à 1280 x 720 :

$ node video-main.js video.mp4 1280x720
Saved video.mp4 to /path-to-video-1280x720.mp4

Cet exemple fonctionne un peu différemment du précédent. Il s’appuie sur le concept de « pool de workers » pour améliorer l’efficacité. Contrairement à l’exemple précédent, il ne met pas automatiquement fin au processus une fois le redimensionnement terminé. Le thread worker reste actif et à l’écoute des messages : vous devrez donc l’arrêter en appuyant sur Ctrl+C dans votre terminal.

Les pools de workers peuvent contribuer à réduire la consommation de ressources. La création d’un worker entraîne une surcharge à chaque fois ; il est donc recommandé de lancer un nombre fixe de threads et de les réutiliser dans la mesure du possible. Poolifier, Piscina et d’autres bibliothèques similaires facilitent l’utilisation des threads worker de cette manière. Elles gèrent la création des threads, jusqu’à la limite définie par l’utilisateur, pour prendre en charge les nouvelles tâches. Les tâches suivantes sont ensuite attribuées aux threads existants dès qu’ils sont disponibles.

Chiffrer des fichiers avec des workers

Cet exemple montre comment associer worker_threads au module crypto de Node.js pour chiffrer un fichier dans un thread distinct.

Commencez par enregistrer le code du thread worker dans un nouveau fichier nommé encrypt-worker.js :

const {parentPort, workerData} = require("worker_threads");
const crypto = require("crypto");
const fs = require("fs");

const {file} = workerData;
const output = `${file}.encrypted`;

const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);

const cipher = crypto.createCipheriv("aes-256-ctr", Buffer.from(key), iv);
const readStream = fs.createReadStream(file);
const writeStream = fs.createWriteStream(output);
readStream.pipe(cipher).pipe(writeStream);
writeStream.on("close", () => parentPort.postMessage({key: key.toString("hex"), output, type: "done"}));

Copiez le code du thread principal dans un nouveau fichier nommé encrypt-main.js :

const {Worker} = require("worker_threads");

// Perform some other tasks here

const fileToEncrypt = process.argv[2];

const worker = new Worker(
    __dirname + "/encrypt-worker.js",
    {
    workerData: {
    file: fileToEncrypt
    }
    }
);

worker.on("message", msg => {
    if (msg?.type === "done") {
    console.log(`File encrypted to ${msg.output}`);
    console.log(`The key is ${msg.key} - don't lose it!`);
    }
});

// Perform some other tasks that don't need to wait for the encryption here

Créez un simple fichier texte dans votre répertoire de travail, prêt à être chiffré, en exécutant la commande suivante dans votre terminal :

$ echo foobar > demo.txt

Exécutez maintenant votre code pour chiffrer le fichier :

$ node encrypt-main.js demo.txt
File encrypted to demo.txt.encrypted
The key is 20eb1974c95553ff4f16638192da8b4cbfd780cec8544579f04cde181b78bd7c - don't lose it!

Dans cet exemple, le worker envoie un message pour indiquer à l’utilisateur le chemin du fichier de sortie et la clé secrète générée.

Les bases des threads worker

Node.js est un environnement d’exécution à thread unique. Cela vient du fait que JavaScript est un langage synchrone qui bloque l’exécution et exécute tout dans un seul thread. Les composants asynchrones de la bibliothèque standard de Node.js semblent ne pas bloquer l’exécution parce qu’ils utilisent un pool de threads distinct.

Le module worker_threads vous permet d’intégrer une solution similaire à votre propre code. Il ne s’agit pas de véritable multithreading, mais, dans de nombreux cas, le résultat s’en approche suffisamment pour vous permettre d’exécuter du code en parallèle, en dehors du thread principal.

Cet article vous a expliqué ce que sont les threads de travail, dans quels cas les utiliser et comment les intégrer à votre projet. Le prochain article de cette série examinera les avantages et les inconvénients des threads de travail et les comparera aux implémentations du multithreading dans d’autres langages de programmation.