Comprendre les Worker Threads de NodeJS

NodeJS utilise Javascript. Javascript est un langage single thread. Mais dernièrement les Worker Threads sont apparus dans la version 10 de NodeJS. Comment ça marche concrètement cette affaire ? A-t-on vraiment le droit à du vrai multi-thread avec NodeJS ?



Dans l’épisode précédant

Alors aujourd’hui je vais te parler de thread en Javascript. Mais si tu connais pas le concept de thread tu vas absolument rien capter à ce que je te raconte. Heureusement j’ai pensé à tout et tu peux te mettre à jour en cinq minutes avec un article que j’ai écrit précédemment sur le fonctionnement de Javascript. Si t’es à jour alors tu sais qu’on a accès à un seul thread pour process ton programme en NodeJS. Et ça limite l’utilisation de NodeJS pour plein de choses.

Quand tu lances un process NodeJS tu lances un thread unique, une seule event loop et une seule instance du moteur Javascript V8. Toutes les opérations synchrones de ton application passent par le même tuyau. Heureusement que toi tu penses comme un hacker de l’extrême et tu utilises des opérations asynchrones pour faire plusieurs choses en même temps. Mais que se passe-t-il si tu veux faire une opération CPU intense de façon synchrone ? Bah je vais te le dire: tu vas utiliser tout ton CPU, bloquer toute ton application, freezer ton UI, ton utilisateur va chialer toute l’eau de son corps et toi tu vas te mettre en boule dans un coin en attendant que ça passe.



index.js

'use strict'

/**
* Use a asynchronous function to make intensive CPU task
*/
async function intensiveCpuTaskIo () {
  console.log('Launching intensive CPU task')

  let increment = 0
  while (increment !== Math.pow(10, 10)) {
    increment++
  }

  console.log('Intensive CPU task is done ! Result is : ', increment)
}

/**
* Use main thread while making intensive CPU task on worker
*/
async function main () {
  // this log will not happen until the intensive task is done, main thread is blocked
  setInterval(() => { console.log('Event loop on main thread is not blocked') }, 1000)

  await intensiveCpuTaskIo()
}

main()

Command

node index.js

Imaginons qu’on te demande d’incrémenter progressivement une valeur jusqu’à dix milliards. Je sais, c’est débile comme demande, mais en tant que dev tu as l’habitude des demandes débiles non ? Bref la façon la plus basique de le faire ressemble au bout de code juste au-dessus. Une fonction asynchrone qui fait un while. La fonction main lance cette fonction asynchrone via le await. Juste avant je rajoute un setInterval qui va logger un message toutes les secondes. Ca me permet de contrôler si l’event loop et donc mon node est toujours utilisable. Mais si tu lances tout ça avec la commande habituelle c’est la grosse catastrophe.



async


Tout est bloqué tant que le while a pas fini de monter jusqu’à 10 milliards. Dans mon exemple de débile je mets juste un setInterval avec un log par soucis de simplicité. Mais ce qu’il faut comprendre ce que tu vas bloquer n’importe quoi qui se trouve sur ton node. Tu fais tourner un serveur express ? Ton site est down. T’as une joli UI ? Tout est freezer. Personne ne bouge tant qu’on est pas arrivé à dix milliards. Et c’est moche.



Worker Threads à la rescousse

Heureusement dans la version 10.5.0 les mecs de NodeJS ont décidé de taper un grand coup sur la table. Ils introduisent le module Worker Threads. Dans le changelog ils expliquent que ce module est une solution pour du multi-threading via NodeJS. Ils confirment également dans la documentation officiel que les Worker Threads sont bien là pour s’occuper des tâches processeurs intensif. Pour mieux comprendre au lieu de te proposer mon traditionnel schéma tout pourri je laisse des pros du beau schéma le faire.



worker threads


Alors si tu regardes à ta gauche c’est la façon traditionnelle dont tu utilises NodeJS. Un process en single thread. Une instance du moteur Javascript V8 couplé à libuv pour les opérations asynchrones. Le tout géré par une seule event loop sur une instance de NodeJS au service de ton code.

À ta droite ce que t’apportent les Worker Threads en terme de nouveautés. Tu as toujours ton process tout seul mais ce process a accès à plusieurs threads grâce au boulot des Worker Threads. Chacun de ces Worker Threads va spawner sa propre event loop grâce à son instance de V8 et libuv. Enfin chacune de ces event loop va vivre sur sa propre instance de NodeJS. Et ton code va avoir accès de façon concurrente à toutes ces instances. D’ailleurs c’est bien beau tout ça mais comment tu codes cette affaire ?



Les Worker Threads dans ton code

Pour faire tourner ce code je turbine sur la version LTS de NodeJS. C’est à dire la version 10.16.0. Si tu viens du futur y’a de forte de chances que ça marche pareil. Si ça marche plus pareil je compte sur toi voyageur du futur pour m’envoyer des insultes par mail et je corrige vite. En attendant check moi ce doux code, j’ai essayé de simplifier la chose à l’extrême, je t’en parle juste après.



index.js

'use strict'

const { Worker } = require('worker_threads')

/**
* Use a worker via Worker Threads module to make intensive CPU task
* @param filepath string relative path to the file containing intensive CPU task code
* @return {Promise(mixed)} a promise that contains result from intensive CPU task
*/
function _useWorker (filepath) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(filepath)

    worker.on('online', () => { console.log('Launching intensive CPU task') })
    worker.on('message', messageFromWorker => {
      console.log(messageFromWorker)

      return resolve
    })

    worker.on('error', reject)
    worker.on('exit', code => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`))
      }
    })
  })
}

/**
* Use main thread while making intensive CPU task on worker
*/
async function main () {
  // this log will happen every second during and after the intensive task, main thread is never blocked
  setInterval(() => { console.log('Event loop on main thread is not blocked right now') }, 1000)

  await _useWorker('./worker.js')
}

main()

Dans le fichier index.js on commence par require le module Worker Threads auquel on va extraire seulement le constructeur de Worker. Ensuite on repart sur notre fonction main. Elle pose toujours un setInterval et va ensuite lancer une autre fonction via un await pour faire le while. Sauf que cette fois cette fonction va retourner une promesse. Dans cette promesse on instancie un worker ligne 12 avec pour paramètre le chemin relatif du fichier du worker qui va faire la tâche intensive. Les workers fonctionnent avec des events ligne 14 à 22. Online est déclenché quand le worker commence à process du code. Message est déclenché quand le worker qui bosse dans l’autre fichier post un message. Enfin error et exit sont là pour gérer les erreurs et la fin de vie du worker.



worker.js

'use strict'

const { parentPort } = require('worker_threads')

let increment = 0
while (increment !== Math.pow(10, 10)) {
  increment++
}

const message = 'Intensive CPU task is done ! Result is : ' + increment

parentPort.postMessage(message)

Dans le fichier worker.js on require toujours le module Worker Threads mais cette fois on récupère parentPort. ParentPort c’est une instance d’un objet (messagePort) déjà instancié pour toi dans les entrailles du module. Cette instance nous permet de discuter entre les threads. On fait donc notre while de l’enfer jusqu’à dix milliards et une fois fini on poste un message pour le thread principal pour lui dire qu’on a fini de bosser. Les Workers Threads étant toujours une feature expérimentale à l’heure ou j’écris ces lignes il faut utiliser un flag pour lancer la commande.



Command

node --experimental-worker index.js


Et là, joie et allégresse, le second thread s’occupe de tout le boulot intensif pendant que c’est la fête au setInterval sur ton main thread. Tu fais vraiment deux choses en même temps avec cette méthode. Il n’y aucune tricherie asynchrone ici. Deux threads, deux moteurs Javascript tournent en concert pour que rien ne soit bloqué. Grâce au Worker Threads tu peux même spawner plusieurs workers en même temps pour qu’ils fassent tous un boulot. Les features ne s’arrêtent pas là car il y a entre autres la communication entre threads et le partage de mémoire entre threads. C’est fou !



worker


Ca existait déjà avant

Alors oui je sais que t’es un hacker et tu connais des façons de contourner ce problème de tâche intensive qui bloque tout. Notamment les modules child process et cluster. Oui ça marche mais c’est moins bien. Et notamment pour une feature qui change tout. Ce que les Worker Threads ont en plus c’est la capacité de se partager la mémoire via des instances ArrayBuffer.

Pourquoi c’est si important ? C’est simple : dans la programmation concurrente le gros problème c’est le risque de race condition et de résultats non prévisibles. Ce type de communication via partage de mémoire entre threads permet d’empêcher ce genre de soucis. Et aujourd’hui en NodeJS seule le module Workers Threads permet cette communication. NodeJS est connu pour sa redoutable efficacité dans un environnement asynchrone de par sa nature non bloquante. Il devient maintenant une solution possible pour de la programmation concurrente avancée.

Cette nouvelle capacité cumulée à une redoutable capacité asynchrone fait de NodeJS un framework intéressant pour de nouveaux domaines. Des domaines fortement dépendant de tâches intensives et répété qui impose un langage de programmation fortement concurrent comme le C++. Et là je te parle de domaines comme l’intelligence artificielle très gourmand en calcul ou de le la science des données utilisée par les data scientist.



Épilogue

Les Worker Threads sont sur une branche expérimentale de NodeJS mais devrait bientôt partir sur une version stable. Vont-t-ils donner un nouvelle élan à NodeJS ? Ca va aider mais c’est pas sûr. Notamment à cause du nouveau runtime fait par le créateur de NodeJS censé remplacer NodeJS dans quelques années. Ce qui est sûr c’est que NodeJS deviendra une solution possible pour de nouveaux horizons. Et avec une communauté aussi importante tu peux être sûr que ça va être exploité à 3000%.

Qui me parle ?

Je suis un dev. En ce moment je suis Backend Développeur / DevOps à Ubisoft. Je suis passionné du dev et expert en blagues. Je continue à te parler quotidiennement sur mon Twitter. Tu peux m'insulter à cette e-mail ou le faire directement dans les commentaires juste en dessous.

Commentaire(s)

    1. Avec pm2 toute ton app nodejs est partagée (c’est à toi de programmer ce que tu veux qui ne soit pas multi thread). Avec les worker thread, c’est toi qui choisi ce qui utilisera plusieurs thread.

      1. Donc on peut utiliser les worker thread sur les traitements qui nous intéressent au cas par cas, et PM2 pour gérer une sorte de LoadBalancing globale de l’ appli .

        Merci pour ta réponse Web-c

  1. Super article et merci,

    Alors tu dis que cela pouvais être fait en child process/cluster et donc maintenant en worker_thread; dans quel cas utilisé chacune de ces trois possibilités ?

    1. En faite les trois sont assez similaires et peuvent gérer du multi thread.

      Dans la vrai vie :
      – Les cluster sont souvent utilisés pour gérer des grosse charges network en repartissant les calls sur plusieurs Node.
      – Les child process sont souvent utilisé pour gérer une tache CPU intensive en lançant par exemple un bash via execFile()

      Enfin comme expliqué dans l’article les Worker Threads ont la nouvelle feature de communication via partage de mémoire.
      Cette feature permet de régler des problèmes avancées de race condition dans une application fortement concurrentiel.
      Ils sont également moins gourmand en terme de mémoire mais pas conseillé à l’utilisation en production aujourd’hui car toujours en expérimental.

T'en penses quoi ?

Your email address will not be published. Required fields are marked *