Le 18 janvier, la version
11.7.0 de la plateforme Node.js a
été annoncée . Parmi les changements notables de cette version, on peut noter la conclusion de la catégorie du module expérimental worker_threads, apparue dans Node.js
10.5.0 . Maintenant, le drapeau --experimental-worker n'est plus nécessaire pour l'utiliser. Ce module, depuis sa création, est resté assez stable, et donc la
décision a été prise, reflétée dans Node.js 11.7.0.

L'auteur du document, dont nous publions la traduction, propose de discuter des capacités du module worker_threads, en particulier, il souhaite expliquer pourquoi ce module est nécessaire et comment le multithreading est implémenté dans JavaScript et Node.js pour des raisons historiques. Nous parlerons ici des problèmes associés à l'écriture d'applications JS multithread, des moyens existants de les résoudre et de l'avenir du traitement parallèle des données en utilisant les soi-disant «threads de travail», parfois appelés «threads de travail» ou simplement des «travailleurs».
La vie dans un monde Ă fil unique
JavaScript a été conçu comme un langage de programmation monothread qui s'exécute dans un navigateur. «Simple thread» signifie que dans le même processus (dans les navigateurs modernes, nous parlons d'onglets de navigateur séparés), un seul ensemble d'instructions peut être exécuté à la fois.
Cela simplifie le développement d'applications, facilite le travail des programmeurs. Initialement, JavaScript était un langage adapté uniquement pour ajouter des fonctionnalités interactives aux pages Web, par exemple, quelque chose comme la validation de formulaire. Parmi les tâches pour lesquelles JS a été conçu, il n’y avait rien de particulièrement compliqué nécessitant le multithreading.
Ryan Dahl , créateur de Node.js, a vu une opportunité intéressante dans cette restriction linguistique. Il voulait implémenter une plateforme serveur basée sur un sous-système d'E / S asynchrone. Cela signifiait que le programmeur n'avait pas besoin de travailler avec des threads, ce qui simplifie considérablement le développement d'une plate-forme similaire. Lors du développement de programmes conçus pour l'exécution de code parallèle, des problèmes peuvent survenir et sont très difficiles à résoudre. Supposons que si plusieurs threads tentent d'accéder à la même zone de mémoire, cela peut conduire à ce que l'on appelle «l'état de course du processus» qui perturbe le programme. Ces erreurs sont difficiles à reproduire et à corriger.
La plate-forme Node.js est-elle monothread?
Les applications Node.js sont-elles monothread? Oui, en quelque sorte. En fait, Node.js vous permet d'effectuer certaines actions en parallèle, mais pour cela, le programmeur n'a pas besoin de créer de threads ou de les synchroniser. La plate-forme Node.js et le système d'exploitation effectuent des opérations d'entrée / sortie parallèles en utilisant leurs propres moyens, et lorsque vient le temps de traiter les données à l'aide de notre code JavaScript, cela fonctionne en mode monotrame.
En d'autres termes, tout sauf notre code JS fonctionne en parallèle. Dans les blocs synchrones de code JavaScript, les commandes sont toujours exécutées une par une, dans l'ordre dans lequel elles sont présentées dans le code source:
let flag = false function doSomething() { flag = true
Tout cela est génial - si tout notre code est occupé à effectuer des E / S asynchrones. Le programme se compose de petits blocs de code synchrone qui fonctionnent rapidement sur les données, par exemple, envoyées aux fichiers et aux flux. Le code des fragments de programme est si rapide qu'il ne bloque pas l'exécution du code de ses autres fragments. Il faut beaucoup plus de temps que l'exécution du code pour attendre les résultats des E / S asynchrones. Prenons un petit exemple:
db.findOne('SELECT ... LIMIT 1', function(err, result) { if (err) return console.error(err) console.log(result) }) console.log('Running query') setTimeout(function() { console.log('Hey there') }, 1000)
Il est possible que la requête vers la base de données présentée ici prenne environ une minute, mais le message de
Running query
sera envoyé à la console immédiatement après le lancement de cette requête. Dans ce cas, le message
Hey there
sera affiché une seconde après l'exécution de la demande, que son exécution soit terminée ou non. Notre application Node.js appelle simplement la fonction qui initie la requête, tandis que l'exécution de son autre code n'est pas bloquée. Une fois la demande terminée, l'application en sera informée à l'aide de la fonction de rappel, puis elle recevra une réponse à cette demande.
Tâches gourmandes en CPU
Que se passe-t-il si nous devons, via JavaScript, faire de l'informatique lourde? Par exemple - pour traiter un grand ensemble de données stockées en mémoire? Cela peut conduire au fait que le programme contiendra un fragment de code synchrone, dont l'exécution prend beaucoup de temps et bloque l'exécution d'un autre code. Imaginez que ces calculs prennent 10 secondes. Si nous parlons d'un serveur Web qui traite une certaine demande, cela signifie qu'il ne sera pas en mesure de traiter d'autres demandes pendant au moins 10 secondes. C'est un gros problème. En fait, des calculs supérieurs à 100 millisecondes peuvent déjà provoquer ce problème.
JavaScript et la plateforme Node.js n'ont pas été initialement conçus pour résoudre des tâches qui utilisent intensivement les ressources du processeur. Dans le cas de JS exécuté dans le navigateur, l'exécution de telles tâches signifie «freins» sur l'interface utilisateur. Dans Node.js, cela peut limiter la possibilité de demander à la plate-forme d'effectuer de nouvelles tâches d'E / S asynchrones et la capacité de répondre aux événements associés à leur exécution.
Revenons à notre exemple précédent. Imaginez qu'en réponse à une requête dans la base de données, plusieurs milliers d'enregistrements chiffrés sont entrés, qui, en code JS synchrone, doivent être déchiffrés:
db.findAll('SELECT ...', function(err, results) { if (err) return console.error(err)
Les résultats, après les avoir reçus, sont dans la fonction de rappel. Après cela, jusqu'à la fin de leur traitement, aucun autre code JS ne peut être exécuté. Habituellement, comme déjà mentionné, la charge sur le système créée par un tel code est minime, il exécute rapidement les tâches qui lui sont assignées. Mais dans ce cas, le programme a reçu les résultats de la requête, qui ont une quantité considérable, et nous devons encore les traiter. Quelque chose comme ça peut prendre quelques secondes. Si nous parlons d'un serveur avec lequel de nombreux utilisateurs travaillent, cela signifie qu'ils ne peuvent continuer à fonctionner qu'après la fin d'une opération gourmande en ressources.
Pourquoi JavaScript n'aura-t-il jamais de fils?
Compte tenu de ce qui précède, il peut sembler que pour résoudre de gros problèmes informatiques dans Node.js, vous devez ajouter un nouveau module qui vous permettra de créer des threads et de les gérer. Comment pouvez-vous vous passer de quelque chose comme ça? Il est très triste que ceux qui utilisent une plate-forme de serveur mature, comme Node.js, n'aient pas les moyens de résoudre magnifiquement les problèmes liés au traitement de grandes quantités de données.
Tout cela est vrai, mais si vous ajoutez la possibilité de travailler avec des flux en JavaScript, cela entraînera un changement dans la nature même de ce langage. Dans JS, vous ne pouvez pas simplement ajouter la possibilité de travailler avec des threads, par exemple, sous la forme d'un nouvel ensemble de classes ou de fonctions. Pour ce faire, vous devez changer la langue elle-même. Dans les langues qui prennent en charge le multithreading, le concept de synchronisation est largement utilisé. Par exemple, en Java, même
certains types numériques ne sont pas atomiques. Cela signifie que si les mécanismes de synchronisation ne sont pas utilisés pour travailler avec eux à partir de différents threads, tout cela peut entraîner, par exemple, après que quelques threads tentent simultanément de modifier la valeur de la même variable, plusieurs octets d'une telle variable seront définis sur un couler, et quelques autres. Par conséquent, une telle variable contiendra quelque chose d'incompatible avec le fonctionnement normal du programme.
Solution primitive au problème: itération de la boucle d'événement
Node.js n'exécutera pas le bloc de code suivant dans la file d'attente d'événements avant la fin du bloc précédent. Cela signifie que pour résoudre notre problème, nous pouvons le diviser en morceaux représentés par des fragments de code synchrone, puis utiliser une construction de la forme
setImmediate(callback)
afin de planifier l'exécution de ces fragments. Le code spécifié par la fonction de
callback
dans cette construction sera exécuté une fois les tâches de l'itération en cours (tick) de la boucle d'événements terminées. Après cela, la même conception est utilisée pour mettre en file d'attente le prochain lot de calculs. Cela permet de ne pas bloquer le cycle des événements et, en même temps, de résoudre des problèmes volumétriques.
Imaginez que nous ayons un grand tableau qui doit être traité, alors que le traitement de chaque élément d'un tel tableau nécessite des calculs complexes:
const arr = [] for (const item of arr) {
Comme déjà mentionné, si nous décidons de traiter l'ensemble du tableau en un seul appel, cela prendra trop de temps et empêchera l'exécution d'un autre code d'application. Par conséquent, nous allons diviser cette grande tâche en parties et utiliser la construction
setImmediate(callback)
:
const crypto = require('crypto') const arr = new Array(200).fill('something') function processChunk() { if (arr.length === 0) {
Maintenant, en une seule fois, nous traitons dix éléments du tableau, après quoi, en utilisant
setImmediate()
, nous planifions le prochain lot de calculs. Et cela signifie que si vous avez besoin d'exécuter un peu plus de code dans le programme, il peut être exécuté entre les opérations de traitement des fragments du tableau. Pour cela, ici, à la fin de l'exemple, il y a du code qui utilise
setInterval()
.
Comme vous pouvez le voir, un tel code semble beaucoup plus compliqué que sa version originale. Et souvent, l'algorithme peut être beaucoup plus complexe que le nôtre, ce qui signifie que, une fois implémenté, il ne sera pas facile de diviser les calculs en morceaux et de comprendre où, afin d'atteindre le bon équilibre, vous devez définir
setImmediate()
, planifier le prochain calcul. De plus, le code s'est avéré être asynchrone, et si notre projet dépend de bibliothèques tierces, nous ne pourrons peut-être pas diviser le processus de résolution d'une tâche difficile en plusieurs parties.
Processus d'arrière-plan
Peut-ĂŞtre que l'approche ci-dessus avec
setImmediate()
fonctionnera bien pour les cas simples, mais elle est loin d'être idéale. De plus, les threads ne sont pas utilisés ici (pour des raisons évidentes) et nous n'avons pas non plus l'intention de changer la langue pour cela. Est-il possible de faire un traitement de données parallèle sans utiliser de threads? Oui, c'est possible, et pour cela, nous avons besoin d'une sorte de mécanisme pour le traitement des données en arrière-plan. Il s'agit de démarrer une certaine tâche, de lui transmettre des données, et pour que cette tâche, sans interférer avec le code principal, utilise tout ce dont elle a besoin, passe autant de temps au travail qu'elle en a besoin, puis renvoie les résultats code principal. Nous avons besoin de quelque chose de similaire à l'extrait de code suivant:
La réalité est que dans Node.js, vous pouvez utiliser des processus d'arrière-plan. Le fait est qu'il est possible de créer une fourchette du processus et de mettre en œuvre le schéma de travail décrit ci-dessus en utilisant le mécanisme de messagerie entre les processus enfant et parent. Le processus principal peut interagir avec le processus descendant, en lui envoyant des événements et en les recevant. La mémoire partagée n'est pas utilisée avec cette approche. Toutes les données échangées par les processus sont «clonées», c'est-à -dire que lorsque des modifications sont apportées à une instance de ces données par un processus, ces modifications ne sont pas visibles par un autre processus. Ceci est similaire à une requête HTTP - lorsqu'un client l'envoie au serveur, le serveur n'en reçoit qu'une copie. Si les processus n'utilisent pas de mémoire partagée, cela signifie qu'avec leur fonctionnement simultané, il est impossible de créer un «état racial», et que nous n'avons pas besoin de nous encombrer de travailler avec des threads. Il semble que notre problème soit résolu.
Certes, en réalité, ce n'est pas le cas. Oui - en face de nous est l'une des solutions à la tâche d'effectuer des calculs intensifs, mais elle est encore imparfaite. La création d'un fork d'un processus est une opération gourmande en ressources. Il faut du temps pour le terminer. En fait, nous parlons de créer une nouvelle machine virtuelle à partir de zéro et d'augmenter la quantité de mémoire consommée par le programme, ce qui est dû au fait que les processus n'utilisent pas de mémoire partagée. Compte tenu de ce qui précède, il convient de se demander s'il est possible, après avoir terminé une tâche, de réutiliser la fourche du processus. Vous pouvez donner une réponse positive à cette question, mais ici, vous devez vous rappeler qu'il est prévu de transférer la fourchette du processus vers diverses tâches gourmandes en ressources qui y seront exécutées de manière synchrone. Deux problèmes peuvent être vus ici:
- Bien qu'avec cette approche, le processus principal ne soit pas bloqué, le processus descendant ne peut effectuer les tâches qui lui sont transférées que séquentiellement. Si nous avons deux tâches, dont l'une prend 10 secondes, et la seconde prend 1 seconde, et nous allons les terminer dans cet ordre, il est peu probable que nous aimions la nécessité d'attendre que la première se termine avant la seconde. Puisque nous créons des fourches de processus, nous aimerions utiliser les capacités du système d'exploitation pour planifier des tâches et utiliser les ressources informatiques de tous les cœurs de notre processeur. Nous avons besoin de quelque chose qui ressemble à un travail sur ordinateur pour une personne qui écoute de la musique et qui parcourt les pages Web. Pour ce faire, vous pouvez créer deux processus fork et organiser l'exécution parallèle des tâches avec leur aide.
- En outre, si l'une des tâches entraîne la fin du processus avec une erreur, toutes les tâches envoyées à un tel processus ne seront pas traitées.
Afin de résoudre ces problèmes, nous avons besoin de plusieurs processus fork, pas un, mais nous devrons limiter leur nombre, car chacun d'eux prend des ressources système et il faut du temps pour créer chacun d'eux. Par conséquent, suivant le modèle de systèmes qui prennent en charge les connexions aux bases de données, nous avons besoin de quelque chose comme un pool de processus prêts à l'emploi. Le système de gestion du pool de processus, à la réception de nouvelles tâches, utilisera des processus libres pour les exécuter, et lorsqu'un certain processus fera face à la tâche, il pourra lui en assigner un nouveau. On a le sentiment qu’un tel schéma de travail n’est pas facile à mettre en œuvre et, en fait, il l’est. Nous utiliserons le package de
ferme de travail pour implémenter ce schéma:
Module Worker_threads
Alors, notre problème est-il résolu? Oui, nous pouvons dire que c'est résolu, mais avec cette approche, il faut beaucoup plus de mémoire que ce qui serait nécessaire si nous avions une solution multithread à notre disposition. Les threads consomment beaucoup moins de ressources que les fourches de processus. C'est pourquoi le module
worker_threads
est apparu dans
worker_threads
Les threads de travail s'exécutent dans un contexte isolé. Ils échangent des informations avec le processus principal à l'aide de messages. Cela nous évite le problème de la «condition de concurrence» auquel sont soumis les environnements multithreads. Dans le même temps, les flux de travailleurs existent dans le même processus que le programme principal, c'est-à -dire qu'avec cette approche, en comparaison avec l'utilisation des fourches de processus, beaucoup moins de mémoire est utilisée.
De plus, en travaillant avec des travailleurs, vous pouvez utiliser la mémoire partagée. Ainsi, spécifiquement à cette fin, des objets de type
SharedArrayBuffer
sont
SharedArrayBuffer
. Ils ne doivent être utilisés que dans les cas où le programme doit effectuer un traitement complexe de grandes quantités de données. Ils vous permettent d'économiser les ressources nécessaires à la sérialisation et à la désérialisation des données lors de l'organisation de l'échange de données entre les travailleurs et le programme principal via des messages.
Flux de travailleur
Si vous utilisez la plate-forme Node.js avant la version 11.7.0, pour activer le travail avec le module
worker_threads
, vous devez utiliser l'
--experimental-worker
lors du démarrage de
--experimental-worker
En outre, il convient de se rappeler que la création d'un travailleur (ainsi que la création d'un flux dans n'importe quelle langue), bien qu'elle nécessite beaucoup moins de ressources que la création d'une fourchette d'un processus, crée également une certaine charge sur le système. Peut-être que dans votre cas, même cette charge peut être excessive. Dans de tels cas, la documentation recommande de créer un pool de travailleurs. Si vous en avez besoin, vous pouvez bien sûr créer votre propre implémentation d'un tel mécanisme, mais vous devriez peut-être chercher quelque chose qui convienne dans le registre NPM.
Prenons un exemple de travail avec des threads de travail. Nous aurons un fichier principal,
index.js
, dans lequel nous allons créer un thread de travail et lui transmettre des données pour le traitement. L'API correspondante est basée sur les événements, mais je vais utiliser ici une promesse qui se résout lorsque le premier message du travailleur arrive:
Comme vous pouvez le voir, l'utilisation du mécanisme de flux de travail est assez simple. À savoir, lors de la création d'un travailleur, vous devez transmettre le chemin d'accès au fichier avec le code du travailleur et les données au concepteur du
Worker
. N'oubliez pas que ces données sont clonées et non stockées dans la mémoire partagée. Après avoir démarré le travailleur, nous attendons un message de sa part, en écoutant l'événement du
message
.
Ci-dessus, lors de la création d'un objet de type
Worker
, nous avons transmis au constructeur le nom du fichier avec le code de travail -
service.js
. Voici le code de ce fichier:
const { workerData, parentPort } = require('worker_threads')
Il y a deux choses qui nous intéressent dans le code des travailleurs. Tout d'abord, nous avons besoin des données transmises par l'application principale. Dans notre cas, ils sont représentés par la variable
workerData
. Deuxièmement, nous avons besoin d'un mécanisme pour transmettre des informations à l'application principale. Ce mécanisme est représenté par l'objet
parentPort
, qui a la méthode
postMessage()
, à l'aide de laquelle nous transmettons les résultats du traitement des données à l'application principale. Voilà comment tout cela fonctionne.
Voici un exemple très simple, mais en utilisant les mêmes mécanismes, vous pouvez construire des structures beaucoup plus complexes. Par exemple, à partir d'un flux de travail, vous pouvez envoyer un grand nombre de messages au flux principal qui contiennent des informations sur l'état du traitement des données au cas où notre application aurait besoin d'un mécanisme similaire. Même du travailleur, les résultats du traitement des données peuvent être renvoyés en plusieurs parties. Par exemple, quelque chose comme cela peut être utile dans une situation où un travailleur est occupé, par exemple, à traiter des milliers d'images, et vous, sans attendre que toutes soient traitées, souhaitez notifier à l'application principale l'achèvement du traitement de chacune d'entre elles.
Les détails sur le module
worker_threads
peuvent être trouvés
ici .
Travailleurs Web
Vous avez peut-être entendu parler des travailleurs du Web. Ils sont destinés à être utilisés dans un environnement client, cette technologie existe depuis longtemps et bénéficie d'un
bon support pour les navigateurs modernes. L'API pour travailler avec les travailleurs Web est différente de ce que nous donne le module Node.js
worker_threads
, c'est tout sur les différences dans les environnements dans lesquels ils travaillent. Cependant, ces technologies peuvent résoudre des problèmes similaires. Par exemple, les travailleurs Web peuvent être utilisés dans les applications clientes pour effectuer le chiffrement et le déchiffrement des données, leur compression et leur décompression. Avec leur aide, vous pouvez traiter des images, mettre en œuvre des systèmes de vision par ordinateur (par exemple, nous parlons de reconnaissance faciale) et résoudre d'autres problèmes similaires dans un navigateur.
Résumé
worker_threads
— Node.js. , , . , , , « ». , ? ,
worker_threads
, Node.js
worker-farm ,
worker_threads
, Node.js .
Chers lecteurs! Node.js-?
