NodeJS : Parlons Worker Threads

Salut !

Aller pour le moment je vais vous laisser respirer loin des tutoriels sur Firebase. Changeons de sujet (mais pas trop non plus). Le 6 Juin 2018 est arrivée la version 10.5.0 de NodeJS et avec un module plutôt interessant : les Worker Threads. Maintenant que le module est stable (depuis la v12 LTS de la fin d’année 2019), je souhaiterais revenir dessus afin d’expliquer un peu le pourquoi du comment et éclairer sur cette bizarrerie les personnes n’ayant pas encore eu l’occasion ou le temps de s’y interesser.

Javascript et son thread "forever alone"

Avant d’entamer les Worker Threads, revenons tranquillement quelques années en arrière lors de la conception et l’avènement du Javascript. Nous sommes en décembre 1995 lorsque Sun Microsystem et Netscape annoncent le langage officiellement. D’ailleurs juste pour la culture personnelle, Javascript s’appelait à la base LiveScript mais comme Sun Microsystem et Netscape étaient partenaires et que La JVM de Sun Microsystem était de plus en plus populaire, les deux gaillards ont décidé de changer le nom en Javascript.

Bref, le Javascript a été conçu pour rendre à l’époque les pages web du navigateur dynamiques et faire de la validation de formulaires. On clique sur un bouton, il se passe des trucs. On scroll, une animation se déclenche et j’en passe. En fait, rien de suffisament méchant pour qu’il y ait besoin de concevoir un langage multithreadé avec toute la complexité que cela implique. Donc le JS fût développé comme un langage de programmation mono-threadé. Cela veut dire qu’une instruction à la fois s’éxécute sur le même processus.

Jusque là, le Javascript était tout de même peu populaire et vu comme un langage brouillon, un peu batard, sans avenir, sous la pluie, seul. Pour cause, niveau performance ce n’était vraiment pas la folie. Cela a duré jusqu’en 2008 quand Google a sorti le moteur V8 (non cher lecteur, pas comme celui de Clément Ader en 1903. Celui là n’avait que 24ch) directement câblé dans Google Chrome, tout nouveau à l’époque.

Pour expliquer de manière brève, le V8 utilise la compilation Just-In-Time en compilant le Javascript en Assembleur au moment de l’éxécution. Cela a fait littéralement exploser les performances du Javascript et par conséquent permis l’apparition des navigateurs web modernes, bien plus puissants qu’à l’origine. Grâce à cela, non seulement la popularité du Javascript est montée de manière exponentielle mais on a vu apparaître des technologies comme NodeJS.

D’ailleurs le but de Ryan Dahl (créateur de NodeJS) était d’implémenter une plateforme basée sur l’asynchrone. Par conséquent, il n’y avait pas besoin de plusieurs threads. Le souci du mono-threading étant le partage mémoire pouvant causer de gros problèmes liés aux accès concurrentiels.

NodeJS étant la plateforme évenementielle Javascript, il est basé sur une architecture non bloquante. Node va donc invoquer les fonctions sans bloquer l’exécution du reste du code. Via la callback, Node est averti que le job est terminé en revoie le résultat. Comme Javascript est rapide et que les tâches ne sont jamais très importantes et en général bien découpées, nous ne remarquons pas la différence.

…sauf quand il s’agit d’une tâche intensive synchrone. Là Node commence à ramer et tout le code se bloque. Pour l’exemple extrême, essayez donc de lancer un while(true) {} dans un script JS. Tout le navigateur tire la tronche. En fait non, n’essayez pas ça à la maison…

On pourrait plus concrètement imaginer un retour de requêtage en base de données qu’il y a besoin de hautement chiffrer/déchiffrer avant de l’utiliser dans l’application. Donc dans le .then({ /**...**/ }) de la promesse, on écrit la série d’instructions permettant de chiffrer/déchiffrer ces données. Nous sommes alors face à un code procédural lourd à la place des bouts de code en général suffisament léger et rapide à exécuter. Dans tous les cas, voilà un exemple qui pourrait occuper le mono-thread de Javascript pour un certain temps, empêchant toute autre instruction d’être prise en charge.

Bref, la popularité de Node ne cesse de grandir, beaucoup d’entreprises commencent à migrer vers cette plateforme et évidemment peuvent rencontrer des problèmes avec ce satané mono-thread.

Et là paf ! Les Worker Threads, on fait du multi-threading en NodeJS c’est cool non ?

Ça n'existait pas déjà ?

Si, mais c’était pas aussi bien. Chapitre suivant.

Ce n’est pas un argument ? Bon, bien sûr qu’il existait déjà différentes solutions afin de palier à ce problème. Notez cependant qu’il s’agit en général de solutions pas entièrement satisfaisantes.

Utiliser par exemple setImmediate(callback) afin de spécifier à Node qu’il peut continuer directement à prendre en charge ce qui est présent de sa queue d’exécution, cela complique très vite les algorithmes et rend le code de moins en moins maintenable au fil de l’écriture et des évolutions.

De la même façon, il est possible de fork le processus. Cependant un fork est particulièrement gourmand en ressources. Ce n’est pas une solution viable à long terme, sans parler de la perte massive en performances.

Enfin il y a le module cluster. Le concept peut encore une fois paraître similaire. En réalité ce module crée plusieurs instances de Node (donc plusieurs processus) avec un processus principal à leur tête servant un peu de routeur pour les requêtes entrantes. Cela permet d’améliorer les performances en effet et de soulager le serveur. Cependant, il ne s’agit pas de multi-threading, vu que chaque processus possède un unique thread dans ce cas-ci. De plus, encore une fois, il n’y a pas de partage mémoire.

Ces solutions (pour ne pas toutes les citer) ne se rapprochent pas de l’ensemble des avantages du multi-threading. Typiquement, les Workers Threads restent plus léger à l’utilisation, partagent le même processus et leur mémoire. Bref du vrai multi-threading.

Les Worker Threads

Si je parle de tâches intensives depuis tout à l’heure, c’est parce que la documentation de NodeJS précise que

Workers (threads) are useful for performing CPU-intensive JavaScript operations.

Documentation officielle

Du coup quelle est la différence ?

A la base l’utilisation de NodeJS s’effectue via un seul processus disposant d’un thread forever alone, on l’a vu plus haut. Ensuite tout est mono instancié. Une instance du moteur V8 (avec libuv pour gérer l’asynchrone) est pris en charge par une unique event loop sur une unique instance de NodeJS.

Okay. Imaginons maintenant que les Worker Threads permettent d’accèder à plusieurs threads. Bien évidemment, tous ces threads sont sur le même processus. Chaque thread possède alors son instance de V8 couplé à libuv et sa propre event loop. Le tout fonctionnant sur une instance de NodeJS. Donc on aura une instance de NodeJS par threads.

Cependant, les-dits threads ne sont pas cloisonnés pour autant puisque le module permet de gérer les accès concurrentiels du code sur les threads.

Aller un schéma vaut mille mots :

Source : The NodeSourceBlog

Et cela donne quoi concrètement ?

Concrètement lorsque l’on déclare un nouveau thread dans le code, on l’associe avec un script JS “isolé” censé exécuter le traitement lourd. Vu comme ça, on pourrait presque comparer avec le fonctionnement d’une Cloud Function.

Mais niveau code voilà ce que ça donne :

index.js

const { Worker } = require('worker_threads')

/** 
* Crée un worker
* @param file On donne à l'objet Worker le chemin d'accès au fichier contenant la tâche à exécuter en parallèle.
*/
function executeWorker (file) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(file)

    // Une fois le worker actif
    worker.on('online', () => { 
      console.log('DEBUT : Execution de la tâche intensive en parallèle') 
    })

    // Si un message est reçu du worker
    worker.on('message', workerMessage => {
      console.log(workerMessage)
      return resolve
    })

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

/**
 * Le main écrit dans la console
 */
async function prog () {

  // Tâche principale
  setInterval( () => { console.log('Tâche principale: la tâche en parallèle peut s\'exécuter') }, 1000)

  // Tâche en parallèle
  await executeWorker('./worker.js')
}

prog()

 

worker.js

const { parentPort } = require('worker_threads')

let count = 0
for (let i = 0; i < 10000000000; i++) {
  count += 1
}
console.log(`FIN: ${count}`)
const message = `Tâche intensive terminée, total : ${count}`

// On renvoie un message depuis le worker récupérer lors du worker.on('message'...)
parentPort.postMessage(message)

 

Et voilà ! Nous avons bien ici un unique processus mais deux threads bien séparés.

Conclusion

Ce nouveau module de l’API de Node semble vraiment pratique et plutôt puissant mais il est peut être un peu tôt pour dire si oui ou non cela sera une feature incontournable de Node.

Les Worker Threads permettent d’installer du multi-threading assez facilement sous Node et ainsi de largement améliorer les performances CPU de la plateforme. Nous pouvons à priori imaginer une ouverture prochaine de NodeJS dans des domaines demandant de la performance CPU comme l’intelligence artificielle par exemple.

Pour donner mon avis personnel, je trouverais super que la plateforme s’ouvre à de nouvelles utilisations mais je souhaite contraster un peu cela. Il faut noter qu’ils ne font pas de miracles particuliers. Le blog NodeSource précise lui même que :

  • Don’t think Workers make everything magically faster, in some cases is better to use Worker pool
  • Don’t use Workers for parallelizing I/O operations.
  • Don’t think spawning Workers is cheap

Il n’y a donc pas de magie. Il reste à considérer l’arrivée (éventuelle) de Deno qui, si il continue sur sa lancée, pourrait peut être compléter voir remplacer NodeJS. Le débat est ouvert, à creuser.

Sur ce il est déjà temps de vous laisser. J’espère que cet article vous aura plu !

Happy coding !

Bien à vous,

Pophip

Rémi Perreira

Consultant en développement