21 Avr 2016

NodeJs est-il monothreadé ?

Souvent, on entend : “Comment fonctionne NodeJS ?”, “Est-il monothreadé ?”, etc. 

En effet, il n’est pas rare de lire (ou d’entendre) que NodeJS est monothreadé (en opposition aux applications JEE ou .Net).

Nous allons à travers cet article expliquer ce qui en est réellement. Que se passe-t-il sous le capot ?

 

Acte 1 : la CallStack

 

Quand vous écrivez du code javascript pour NodeJS, chaque instruction est empilée dans une pile (“callstack”) et exécutée en mode FIFO.

Analysons le code suivant, comment sera-t-il traité ?

 

 

Dans la stack, tous les appels liés à “searchTheBestPlayer” vont s’empiler :

Au début, la pile va contenir :

 searchTheBestPlayer(datas);

puis :

maxByScore(datas)

searchTheBestPlayer(datas);

et :

_.maxBy( datas, function(o){ return o.score; } );

maxByScore(datas)

searchTheBestPlayer(datas);

Ensuite, un composant mystère (attention teasing…) va les dépiler. La pile devient :

maxByScore(datas)

searchTheBestPlayer(datas);

puis :

searchTheBestPlayer(datas)

Et maintenant le “console.log( JSON.stringify( bestPlayer ) );” fait son entrée dans la stack et comme la ligne contient deux instructions nous aurons donc deux éléments dans la pile :

console.log( JSON.stringify( bestPlayer ) );

puis :

JSON.stringify( bestPlayer )

console.log( JSON.stringify( bestPlayer ) );

et enfin :

console.log( JSON.stringify( bestPlayer ) );

qui affichera dans la console :

 

Quand on évoque une “stacktrace”, c’est bien de cette pile d’instructions dont on parle. Par exemple, si au milieu de cette pile (id est, présent dans votre code), un “throw new Error(‘Something broke’)” est interprétée alors vous aurez la pile des méthodes ayant menées à cette erreur dans votre console.

 

Idem, quand vous appelez faites des appels récursifs et que la condition de fin n’est jamais rencontrée alors les appels à “brokeTheStack” vont s’empiler jusqu’à ce que ça explose (la pile ayant atteint sa capacité max…) :

 

 

Dernière conclusion, et non des moindres… si votre programme empile énormément d’instructions (on parlera de traitement CPUvore, exemple : la suite de fibonacci – implémentation classique en mode récursif) alors peut-être que le dépilement des instructions n’ira pas assez vite. L’exécution de votre code est comme “bloquée” (il se passera la même chose si vous faites de manière “synchrone” un appel à un service distant qui met du temps à répondre). Et oui, votre code NodeJS n’est pas traité en parallèle, tout s’exécute séquentiellement

Cela peut paraître une limitation de NodeJS mais ne sortez pas vos mouchoirs trop vites, NodeJS n’a pas dit son dernier mot.

 

… Sinon comme vous l’aurez devinez c’est la fameuse “Event Loop” qui va dépiler les différentes instructions. Voyons cela ! …

 

 

Acte 2 : l’EventLoop

 

L’ “Event Loop” NodeJS est exécuté au sein d’un unique thread (d’où le fait que l’on dise de NodeJS qu’il est monothreadé). C’est l’EventLoop qui va évaluer votre code séquentiellement. Elle donne aussi la capacité à votre application NodeJS d’encaisser de nombreuses requêtes concurrentes. En effet, contrairement à Apache, NodeJS n’a pas le besoin de démarrer un nouveau thread à chaque requête http entrante et du coup NodeJS va pouvoir encaisser énormément de requêtes sans pour autant nécessiter beaucoup de mémoire…

 

Question : Comment développe-t-on avec NodeJS des appels à des API distantes qui peuvent être longues à répondre ?

Ou plus généralement : Comment travaille-t-on avec NodeJS dès que nous avons besoin de faire l’I/O ?

 

Et bien c’est là qu’intervient la programmation asynchrone :

En d’autres termes, en tant que développeur, je vais utiliser du code asynchrone. Et lors de l’appel à ces fonctions asynchrones, je demanderai à NodeJS d’exécuter un “bout de code” bien particulier (bref une callback). Et quand NodeJS aura fini d’exécuter ma fonction asynchrone, il déroulera ma callback. NodeJS va donc éxécuter “quelquepart” ma fonction asynchrone et retenir qu’il doit jouer la callback. Pendant ce temps, l’event loop est libre de traiter autre chose (une autre requête, la suite de mon code, etc.) Une fois le traitement asynchrone terminé, l'”event loop” est prévenu et va pouvoir jouer la callback.

 

Nous verrons plus tard où est ce “quelquepart” et en quoi il consiste. Retenons que pour rester performante l'”event loop” va déléguer les appels nécessitant de l’I/O afin de se concentrer sur le code présent dans la CallStack.

 

Réalisons le même exercice mais avec ce code asynchrone :

 

(Je n’ai pas mis de numéro d’étape dans le console.log du setTimeout car justement sa réalisation est “imprévisible” dans le temps)

 

Dans la stack, on retrouve :

Au début, la pile va contenir :

console.log( “Etape 1” );

puis :

console.log( “Etape 1” );

setTimeout( ….)

et :

console.log( “Etape 1” );

setTimeout( ….)

console.log( “Etape 2” );

qui vont être dépilés ainsi :

setTimeout( ….)

console.log( “Etape 2” );

au même moment dans la console :

puis :

console.log( “Etape 2” );

qui affichera au même moment dans la console :

Et environ deux secondes plus tard (nous reviendrons sur le “environ” par la suite), dans la stack, les console.log des étapes 1 et 2 ont disparu, mais une nouvelle entrée fait son apparition :

console.log( “Etape asynchrone” );

qui affichera au même moment dans la console :

 

Que comprendre ? le “setTimeout” a été interprété par l’EventLoop, cette dernière a délégué l’exécution du “setTimeout” à un autre composant. Une fois le “setTimeout” exécuté par ce composant, on aperçoit dans la stack les instructions de la callback qui sont interprêtés par l’EventLoop.

 

Sinon utilisons une image : quand vous allez au guichet d’un organisme d’état, l’agent vous donne un formulaire à remplir et vous demande de vous éloigner pour le remplir et de revenir lui rendre à la fin du remplissage afin de passer à la suite de votre demande. Pendant ce temps, il a pu traiter les demandes d’autres clients. Si l’organisme d’état avait été en mode “Apache” on aurait juste cloner plus d’agents…

Autre image du monde réel : dans un restaurant, un serveur va agir de la même manière. Il ne va pas :

  • vous accueillir,
  • attendre votre commande,
  • attendre le cuisinier,
  • attendre la fin de votre repas,
  • passer au client suivant,
  • etc.

Il va plutôt se comporter ainsi :

  • vous accueillir,
  • faire “autre chose”,
  • prendre votre commande,
  • faire “autre chose”,
  • vous servir à manger,
  • faire “autre chose”,
  • etc.

 

Petit résumé de l’Acte 1 et l’Acte 2 :

NodeJs Internal behavior

 

Acte 3 : les workers

 

Comme nous le disions, les workers vont être en charge de tout ce qui est :

  • traitement longs,
  • traitements asynchrones,
  • traitements d’accès aux donnés,
  • traitements nécessitant de l’I/O.

Vous n’avez pas accès à ces workers, vous ne pouvez pas en dicter le comportement. Concrètement, ces workers sont des threads indépendants placés dans un pool uniquement accès accessible par NodeJS.

Et bien c’est là qu’intervient la programmation par événement :

En informatique, la programmation évènementielle est un paradigme de programmation fondé sur les événements. Elle s’oppose à la programmation séquentielle. Le programme sera principalement défini par ses réactions aux différents événements qui peuvent se produire, c’est-à-dire des changements d’état de variable, par exemple l’incrémentation d’une liste, un mouvement de souris ou de clavier.

(Merci wikipedia – N’oubliez pas de faire un don ! )

Quand un thread aura fini un traitement, il va envoyer un message dans une queue. Le message sera ensuite lu dès que la callstack est vide et le traitement callback lié à ce message sera mis dans la callstack pour que l’EventLoop l’exécute.

Petit résumé des actes 1, 2 et 3 : 

Workers

Revenons sur notre environ :

Et environ deux secondes plus tard, dans la stack, les console.log des étapes 1 et 2 ont disparu, mais une nouvelle entrée fait son apparition..

Comme indiqué précédemment, il faut que la callstack soit vide pour que le console.log du setTimeout soit interprété… cela peut demander du temps… On comprend bien aussi qu’il faut veiller a ce que les traitements asynchrones ne polluent pas trop la queue.

 

En complément, il faut noter que pour certains de ces workers, des librairies bas niveau C++ sont utilisées. Par exemple, pour la partie I/O, libuv est utilisé.

 

 

Conclusion

 

Nous avons donc vu que l’EventLoop est exécuté au sein d’unique thread et qu’il est très important de développer en asynchrone afin de ne jamais bloquer l’EventLoop. Tous les traitements longs, nécessitant de l’I/O, etc. sont délégués à des threads indépendants sur lesquels nous n’avons pas la main. Les résultats de ces traitements vont être communiqués à l’EventLoop qui pourra en exécuter les callback.

 

NodeJS, pour écrire quel type de traitements ?

  • Encaisser de multiples connexions concurrentes : web sockets, real-time messaging systems, etc.
  • Encaisser de gros volumes de requêtes nécessitant peu de traitements CPU : API Rest exposant du JSON,
  • Real-time Pub/Sub systems – Des applications à l’écoute de nombreux événements ou notifications qui nécessitent des traitements temps réels alors NodeJS est un bon choix pour encaisser et répondre rapidement.

 

Share
  • Fabrice Woittequand

    “Tous les traitements longs, nécessitant de l’I/O, etc. sont délégués à des threads indépendants sur lesquels nous n’avons pas la main”
    Tous les traitements effectivement topés I/O rentrent dans cette catégorie, mais les traitements style mapping, transformations, calculs, etc que l’on trouve dans les callbacks et qui influent sur le résultat final d’une promise par exemple, sont effectués par le thread de l’event-loop lui-même et il n’y a pas de délégation dans ce cas. Il faut donc être prudent lorsque l’on fait ce genre de chose en Node.