Le multithreading dans Node.js avec les worker threads : avantages et inconvénients
James Walker
27 février 2023
0 minutes de lectureNode.js présente à votre application une boucle d’événements monothread, ce qui permet aux opérations gourmandes en CPU de bloquer le thread principal et de provoquer des ralentissements. Le module worker_threads résout ce problème en fournissant un mécanisme permettant d’exécuter du code en parallèle à l’aide d’une forme de threading.
Dans un article précédent, vous avez découvert ce que sont les worker threads, leurs cas d’usage courants et comment les ajouter à votre projet. Dans cet article, nous examinons leurs pièges et leurs différences avec les implémentations du multithreading dans d’autres langages de programmation. Nous passerons également en revue cinq bibliothèques populaires qui facilitent l’utilisation du module worker_threads.
Pièges et points à surveiller avec les worker threads
Les avantages des worker threads se résument facilement : c’est le seul moyen d’obtenir quelque chose qui s’apparente au multithreading avec Node.js. Les opérations gourmandes en CPU, le traitement en arrière-plan et toute exécution de code en parallèle, à l’exception des E/S asynchrones, peuvent être mis en œuvre avec des worker threads.
Le module et le concept qu’il met en œuvre comportent toutefois plusieurs réserves. Vous devez les connaître avant de commencer à implémenter vos workers, car certaines situations ne se prêtent pas à la parallélisation avec ce mécanisme.
Les worker threads ne sont pas de véritables threads
La première restriction, et la plus importante, des worker threads est qu’il ne s’agit pas de threads au sens conventionnel. Les véritables applications multithreadées permettent à plusieurs threads de s’exécuter simultanément en partageant par défaut le même état. Dans Node.js, les modifications de mémoire effectuées dans un thread ne sont pas visibles par les autres. L’écriture de code multithreadé exige donc une gestion rigoureuse de la mémoire pour éviter les conditions de concurrence.
Les worker threads de Node.js fonctionnent indépendamment du code JavaScript du processus principal. Ils sont créés en lançant une instance isolée du moteur d’exécution JavaScript V8 de Node. Ce nouvel environnement d’exécution peut ensuite servir à exécuter un fichier JavaScript en dehors de la boucle d’événements principale.
Comme le fichier est exécuté de cette manière, il n’y a aucun partage implicite de mémoire entre le programme principal et le « thread » worker. À la place, un système de messagerie basé sur les événements permet d’échanger des valeurs entre les processus.
Ce code, que vous avez examiné en détail dans la première partie, crée un processus worker qui reçoit une valeur (hello) du thread principal et la renvoie sous une autre forme (You said "hello".). La fonction postMessage() sert à envoyer des données d’un côté à l’autre de la séparation entre le thread principal et le worker. Les variables définies d’un côté ne sont pas visibles de l’autre.
Il existe une exception à cette règle : vous pouvez utiliser un SharedArrayBuffer pour partager directement de la mémoire entre les threads, en l’allouant explicitement en mémoire partagée :
Si vous enregistrez ce code dans shared.js et exécutez le fichier, le résultat suivant s’affiche :
Cela fonctionne parce que le worker thread peut accéder à la zone de mémoire partagée créée par le thread principal. Lorsque celui-ci examine ensuite les données, il voit les modifications écrites par le worker. Il ne s’agit toujours pas d’un véritable partage d’état, car vous devez rendre le tableau accessible au worker manuellement, à l’aide de l’option de constructeur workerData.
Créer trop de worker threads coûte cher
Créer un worker thread ne revient pas à lancer un nouveau thread dans un langage multithreadé. Chaque worker thread exécute sa propre instance du moteur JavaScript V8. En utiliser trop consomme donc beaucoup de ressources sur votre hôte.
Même si les workers démarrent rapidement, leur lancement entraîne toujours un surcoût. Cette opération est relativement coûteuse, ce qui rend les worker threads inadaptés aux tâches légères. Il vaut mieux les réserver au traitement en parallèle d’activités gourmandes en CPU, pour lesquelles les gains de performances compensent largement le coût de création du processus.
Vous pouvez limiter ces inefficacités en réutilisant un pool de worker threads, ce qui évite de payer à chaque fois le coût de création de nouveaux workers. Des bibliothèques comme Piscina et Poolifier vous évitent d’avoir à gérer la complexité d’un pool de workers.
Utiliser des worker threads pour les E/S est inefficace
De par leur nature, les worker threads ne sont pas adaptés aux tâches d’E/S. Vous n’avez pas besoin de worker threads pour lire un fichier ou récupérer des données sur le réseau : Node.js intègre déjà de meilleures solutions asynchrones.
La documentation de worker_threads déconseille explicitement d’utiliser le module dans ces situations. Le coût de création et de maintenance du processus worker, qui dispose de son propre moteur V8, est bien moins efficace que les implémentations d’E/S asynchrones de Node.js. En implémentant ces tâches avec des worker threads, vous dégraderez les performances, gaspillerez des ressources et écrirez du code redondant.
Le débogage des worker threads peut être difficile
Les worker threads regroupés dans un pool peuvent être difficiles à déboguer, car il n’est pas toujours facile d’établir un lien entre un événement, le worker qui le traite et l’effet produit. Essayer de comprendre ce qui se passe à l’aide d’instructions console.log() est fastidieux et source d’erreurs.
Pour obtenir des informations de diagnostic plus utiles, vous pouvez associer une ressource AsyncResource à votre pool. Vous disposerez ainsi de traces complètes des piles d’appels asynchrones, qui suivent ce qui se passe au sein du pool et vous permettent de voir toute la séquence d’activités ayant conduit à un effet donné.
Le partage de mémoire à l’aide d’un SharedArrayBuffer peut également être source de problèmes. Vous devez utiliser des opérations atomiques ou mettre en œuvre votre propre système de gestion de la concurrence pour éviter les conditions de concurrence lors de l’accès à la mémoire partagée et de sa modification. Si de telles conditions surviennent, elles peuvent provoquer des symptômes étranges dans votre application et sont souvent difficiles à repérer, surtout lorsqu’elles concernent une mémoire utilisée à de nombreux endroits.
Les principales bibliothèques de threading pour Node.js
Le module worker_threads intégré à Node.js se concentre sur les bases de la création de worker threads et de l’échange de données avec eux. Voici cinq bibliothèques populaires qui encapsulent ce module pour proposer une interface plus pratique ou des fonctionnalités avancées, comme la gestion de pools de threads.
Piscina
Piscina facilite l’utilisation de pools de workers. Vous pouvez créer vos propres files d’attente de tâches, suivre leur exécution et annuler une tâche exécutée par un worker si elle s’avère superflue.
Voici un exemple simple avec Piscina. Enregistrez ce code dans main.js :
Ajoutez ensuite ce code dans worker.js :
Installez le package Piscina à l’aide de la commande suivante :
Lorsque vous exécutez node main.js, le message You said hello s’affiche dans votre terminal. Piscina offre une interface plus pratique pour l’API worker threads.
Bree
Bree est un planificateur de tâches pour Node.js. Il vous permet d’exécuter des tâches asynchrones à intervalles définis. Vous pouvez configurer pour chaque tâche des limites de concurrence, des mécanismes de nouvelle tentative et l’annulation. Bree utilise des worker threads en interne pour exécuter le code des tâches en dehors de la boucle principale.
Installez Bree avec npm :
Créez maintenant un fichier nommé bree-main.js contenant le code suivant :
Ajoutez le code suivant dans jobs/bree-job.js :
L’exécution de node bree-main.js affiche l’heure immédiatement, puis toutes les cinq secondes :
Poolifier
Poolifier est une autre implémentation de pool de workers. Elle vous permet de gérer plusieurs workers sans vous imposer la complexité de la gestion du pool. Les pools peuvent être fixes, c’est-à-dire contenir un nombre défini de workers réutilisés, ou dynamiques, auquel cas des workers sont ajoutés selon les besoins jusqu’à atteindre la limite configurée par l’utilisateur.
Vous pouvez créer un pool simple pour exécuter un fichier donné dans un worker thread en ajoutant le code suivant dans main.js :
Définissez le code à exécuter dans le pool fixe en ajoutant le contenu suivant dans fixed-worker.js :
Ajoutez maintenant le code à exécuter dans le pool dynamique dans dynamic-worker.js :
Installez le package poolifier depuis npm, puis exécutez main.js avec Node. Le résultat suivant devrait s’afficher au démarrage des deux pools de threads et à l’exécution de leurs tâches :
Le processus continue de s’exécuter jusqu’à ce que vous l’arrêtiez en appuyant sur Ctrl+C. Poolifier maintient les pools de threads disponibles pour traiter de nouvelles tâches et empêche le processus de se terminer tant qu’il reste des pools actifs.
Les worker threads face aux autres langages de programmation
Les langages de programmation mettent en œuvre le multithreading de différentes manières. Dans Node.js, il s’agit du système multiprocessus fourni par le module worker_threads. Voici ce que proposent quelques autres langages.
C/C++ : Langages de bas niveau, C et C++ proposent tous deux un véritable multithreading grâce à la bibliothèque POSIX de threading pthreads. C++ dispose également d’un objet thread dans son espace de noms standard, pour gérer la concurrence encore plus simplement. Vous devez utiliser vous-même des opérations atomiques et des mutex pour synchroniser correctement la mémoire et éviter les conditions de concurrence.
Java : Java propose également le véritable multithreading avec la classe
Threadou son interfaceRunnable. Un ensemble complet d’outils de gestion de la concurrence vous aide à gérer les threads que vous avez créés.Python : Python dispose d’une bibliothèque de threading, mais CPython, l’implémentation la plus répandue du langage Python, ne peut exécuter qu’un seul thread à la fois. Ainsi, bien que le threading semble disponible, il n’accélère pas le code gourmand en CPU. Python propose également le module multiprocessing, qui adopte une approche similaire aux worker threads de Node.js.
Ruby : Ruby se trouve dans une situation similaire à Python. Le langage dispose d’une prise en charge complète des threads, mais MRI Ruby, son implémentation la plus répandue, ne prend en charge qu’un seul thread à la fois. En revanche, les interpréteurs alternatifs plus récents, comme JRuby et Rubinius, prennent bien en charge le véritable multithreading.
Rust : Rust offre une prise en charge complète du multithreading. Il permet de créer facilement des threads et de partager des données entre eux, avec un faible risque d’erreurs. La conception du langage rend impossibles de nombreux bugs de concurrence courants, ce qui en fait un excellent choix pour les projets fortement dépendants du multithreading.
Le threading en C/C++, Rust et Java sera bien plus rapide que le modèle des sous-processus de Node.js. Ces langages exposent de véritables threads, avec un état partagé et toutes les problématiques de gestion de la mémoire que cela implique. Les langages interprétés de plus haut niveau, comme Python, Ruby et Node.js, ne proposent pas d’implémentation native des threads et s’appuient plutôt sur des solutions plus lourdes faisant appel à des sous-processus worker.
Pour conclure
Les worker threads permettent aux développeurs Node.js d’exécuter du code en parallèle en lançant de nouveaux processus enfants. Il ne s’agit toutefois pas de véritable multithreading : chaque « thread » est un processus indépendant qui n’a pas accès au contexte de son parent. La communication entre les threads n’est possible qu’au moyen de mémoire partagée allouée et de messages échangés via un écouteur d’événements.
Le module worker_threads reste un élément précieux de l’écosystème Node.js. C’est le seul moyen d’obtenir du multithreading et du traitement en parallèle dans les limites imposées par JavaScript, un langage synchrone qui bloque l’exécution. Il est toutefois important de connaître les limites des worker threads pour choisir en connaissance de cause quand les utiliser. Ajouter un worker dans une situation inadaptée peut réduire les performances de votre application et augmenter sa consommation de ressources.
Pour les portions de code très gourmandes en CPU où les performances sont essentielles, l’utilisation de véritables threads dans un autre langage de programmation sera plus performante. Les worker threads suffisent toutefois à la plupart des cas d’usage de Node.js, comme les files d’attente de tâches dans les applications web ou le traitement vidéo en arrière-plan sur votre ordinateur.


