Boucle d’événements et multithreading dans Node.js

En un peu plus d'une dizaine d'années, Node.js s'est imposé comme un élément central des architectures Web. A en lire des articles inventoriant les grandes entreprises qui l'utilisent, comme ici, Node.js est utilisé par Netflix, eBay, PayPal, et j'en passe.
Node.js
Joli succès pour cette technologie sortie de l'esprit de Ryan Dahl, qui a su repenser "out of the box" une manière de fonctionner des serveurs HTTP que plus personne ou presque n'interrogeait, se reposant dessus comme sur un acquis.
Pourtant, en dépit de sa popularité, il apparaît que les bases du fonctionnement de Node.js sont très mal maîtrisées. Au coeur du sujet : le fonctionnement de la boucle d'événements, et la place qu'elle laisse au multithreading.
Retour à base, pour tenter de comprendre non seulement ces aspects, mais aussi les raisons pour lesquelles ils peuvent être encore méconnus.

Avertissements

Dans ce qui suit, je vais évidemment me baser sur une présentation de Node.js par Ryan Dahl, qui en est l'auteur, mais aussi sur des présentations de deux contributeurs à Node.js à l'occasion d'une conférence consacrée à la technologie fin 2016 : Bert Belder et Sam Roberts.
Ces présentations sont donc anciennes, mais l'on verra qu'elles restent importantes, et que cette importance ne se réduit pas à leur contenu : il s'étend à leur existence même. Autrement dit, ce qu'il sera intéressant de relever, ce n'est pas seulement ce qui est expliqué, c'est aussi pourquoi il a fallu en venir à l'expliquer.
Aussi, je précise que je ne rentrerai pas totalement dans le détail de la boucle d'événements et de la place du multithreading. Mon point est de dégrossir des sujets qui me sont apparu très grossièrement, pour ne pas dire très vulgairement, présentés dans quantité d'articles sur le Web, lorsque j'ai cherché à en savoir plus sur Node.js.

Node.js, la version simple (voire simpliste ?)

La nature de Node.js est un peu délicate à saisir. Node.js doit être vu comme un programme qui exécute notamment un moteur, lequel s'appuie sur l'interpréteur de JavaScript tiré de Chrome, dit moteur de JavaScript V8, pour exécuter des programmes JavaScript. Par ailleurs, Node.js donne à ces programmes accès à une API extensible, qui leur permet à la base d'attaquer le système d'exploitation plutôt que le navigateur - vu d'un programme, l'objet global principal est process, et non window.
Donc à la base, exécuter un script en ligne de commandes à partir de la console... :
C:\Temp\node-v14.15.4-win-x64> node
Welcome to Node.js v14.15.4.
Type ".help" for more information.
> i=1
1
...et au-delà, exécuter un script depuis un fichier :
C:\Temp\node-v14.15.4-win-x64> node test.js
Pour installer Node.js, il suffit de récupérer ici la version pour Windows 64 bits au format ZIP, et de la décompresser dans un dossier.
Node.js est accompagné de npm, acronyme pour Node.js Package Manager. Pour un programmeur en Python, c'est l'équivalent de pip - Python Installer Program. Bref, c'est un outil en ligne de commandes qui permet de gérer les packages auxquels les programmes peuvent accéder, en permettant d'abord d'en installer parmi ceux qui figurent sur le dépôt PyPI - Python Package Index.
Un package intégré à Node.js est "http". Il permet de faire tourner un serveur HTTP. Par exemple, un fichier server.js :
var http = require ('http');
var server = http.createServer (function (request, response) {
	response.write ('Hello world');
	response.end ();
});
server.listen (3000);
Donc tout simplement lancer le serveur en ligne de commandes... :
C:\Temp\node-v14.15.4-win-x64> node server.js
...et y accéder via un navigateur :
http://localhost:3000
La particularité d'un tel serveur est une particularité de Node.js. Pour le comprendre, rien de mieux que de reprendre un exemple donné ici par Ryan Dahl, l'auteur de Node.js. Dans cette vidéo, il compare deux manières dont on pourrait écrire un programme qui doit commander l'exécution d'une fonction après deux secondes.
En PHP, il faudra demander au moteur de se bloquer deux secondes avant d'exécuter la fonction :
sleep (2);
echo ("Hello world");
Avec Node.js, il faudra demander au moteur d'exécuter la fonction après deux secondes, en lui fournissant sous la forme d'une callback :
setTimeout (function () { console.log ("Hello world") }, 2000);
Le point, c'est que Node.js a été conçu pour n'offrir aucune possibilité pour un programme de bloquer le moteur, c'est-à-dire condamner le moteur à ne rien faire. Ce qui permet à Ryan Dahl de dire, au prix d'un abus de langage où il assimile Node.js au moteur, que "Node.js doesn't sleep".

Node.js, la version complexe (voire compliquée ?)

A vrai dire, cette formule est assez malheureuse, car elle réduit Node.js à sa boucle d'événements, et laisse donc de plus entendre que Node.js ne fonctionnerait jamais qu'avec un seul thread.
Or, il faut souligner que la part du multithreading dans Node.js apparaît comme aussi mal comprise que le fonctionnement de sa boucle d'événements, deux sujets intimement liés. Il suffit de faire quelques recherches sur ces sujets sur le Web pour s'en convaincre. Plus généralement, c'est ce que pointe Sam Roberts, au début de cette présentation qu'il consacre à la boucle. Pour justifier l'intérêt de son sujet - pourquoi revenir sur un fonctionnement de base devant un public intéressé par les usages avancés ? -, il rapporte que chaque fois qu'il interroge des personnes sur le fonctionnement interne de Node.js :
one of my go-to questions is what's a Node event loop and my secondary question is Node single-threaded or multithreaded, and a suprisingly number of people had an either unclear, wrong or muddy answer to those questions.
C'est une nouvelle illustration d'un problème toujours plus commun : depuis que le Web a doté chacun d'un porte-voix, il est devenu très difficile de trouver une explication valable du fonctionnement interne nécessairement complexe d'une technologie simple à utiliser. C'est qu'il en va de l'informatique comme de la vraie vie, tout particulièrement en ces temps complotistes : on croise toujours plus de croyants que de savants sur le chemin de la connaissance. En conséquence, il faut se méfier de la crédulité, en premier lieu de la sienne. Rien de plus nuisible que celui qui croit avoir compris sans avoir fourni le moindre effort pour cela, comme s'il suffisait d'avoir vu la Vierge...
Ainsi, on entend souvent dire que Node.js est single-threaded. C'est un abus de langage, qui découle d'une réduction de Node.js au moteur, lui-même souvent réduit à l'interpréteur, du fait que v8 est désigné comme un moteur de JavaScript. Prétendre que Node.js est single-threaded, ce n'est vrai que d'un certain point de vue, celui des programmes que Node.js exécute, car le moteur est une boucle d'événements qui s'exécute dans un unique thread. Du point de vue du système d'exploitation qui exécute Node.js, ce dernier est bien multihreaded.
Quant à la boucle d'événéments, Bert Belder, contributeur à Node.js, montre dans une autre présentation qu'elle fait autant l'objet de conceptions erronées, en démontant les schémas incongrus de la boucle qui sont légion sur le Web.
De quoi en retourne-t-il vraiment ? Bert Belder explique que pour un script donné, la boucle répète la séquence suivante :
  • passer en revue une liste des timers programmés par setTimeout () ou setInterval (), et appeler les callbacks de ceux qui ont expirés ;
  • appeler une fonction "unicorn", qui, durant un temps limité, vérifie si des opérations d'I/O demandées au système sont terminées et retourne les événements correspondants s'il y en a ;
  • exécuter le code dont l'exécution doit être immédiate, demandée par setImmediate () ;
  • nettoyer, c'est-à-dire déterminer si plus aucun timer ni opération I/O n'est en cours, et de là acter la fin du script donc la sortie de la boucle.
Bref, comme la définit Sam Roberts, la boucle des événements de Node.js, c'est "a semi-infinite loop, polling and blocking on the O/S until some in a set of file descriptors are ready" - il évoque des descripteurs car il se fonde sur l'implémentation de Node.js pour Linux. La boucle se termine quand elle n'a plus rien à attendre, ce qu'elle apprécie par l'absence de références sur des ressources qui induisent une attente, comme un timeout, une socket... Noter que depuis un programme, il est possible d'appeler une méthode .unref () pour signifier qu'il n'est plus utile d'attendre une telle ressource.
Un point que Bert Belder évoque un peu rapidement au regard de son caractère essentiel, c'est que pour permettre la montée en charge, les auteurs de Node.js font tout leur possible pour éviter d'utiliser des threads lorsqu'il faut utiliser des fonctions du système d'exploitation, au point de ne pas hésiter à passer par le kernel plutôt que par les bibliothèques - ce qui n'est malgré tout pas une garantie de succès.
On comprend que dans un monde idéal des auteurs de Node.js, la boucle devrait se contenter de consulter le système pour savoir si les opérations qu'elle demande à ce dernier d'accomplir ont été réalisées, plutôt que le système ne vienne l'interrompre - toute la différence entre polling et interruption, bien familière pour ceux qui ont programmé bas niveau, comme en assembleur sur Amiga. Toutefois, ce n'est pas toujours possible.
Pourquoi les auteurs de Node.js souhaitent-ils cela ? Parce que c'est ainsi que la boucle peut maîtriser vraiment la séquence des opérations qu'elle accomplit, et donc le temps qu'elle y consacre. C'est une condition essentielle pour optimiser le traitement des événements, donc les performances de Node.js généralement.
Dès lors, si la boucle doit malgré tout demander au système d'accomplir une opération durant laquelle ce dernier doit l'interrompre pour l'informer du déroulement de l'opération en question, la boucle crée un thread qui sera interrompu à sa place. Le thread stocke une trace de cette interruption à un endroit où la boucle peut le trouver quand elle souhaite. Autrement dit, le thread joue les intermédiaires pour transformer une mécanique à base d'interruption en un mécanisme à base de polling au moyen d'un buffer. Et oui, cela s'apparente clairement au pipe.
C'est clairement embarrassant pour les auteurs de Node.js, comme le conclut Bert Belder :
So if you need to make any diagrams, please don't pretend everything happens in a thread pool. That's very painful for me, because we try very hard not to do that.
En effet, mettre en place le machin évoqué à l'instant ne va pas sans consommer des ressources, et donc limiter la capacité à monter en charge. Ce n'est donc jamais que forcés et contraints par le système d'exploitation que les auteurs de Node.js se résignent à utiliser des threads. D'où l'existence d'un petit pool de quatre threads malgré tout, nombre qu'il est possible d'ajuster via la variable d'environnement UV_THREADPOOL_SIZE.
Quelles opérations passent par un thread ?
Sam Roberts explique à quelles opérations il vient servir pour ce qui concerne l'implémentation de Node.js pour Linux - pour rappel, nous somme alors fin 2016 :
  • fs.* () ;
  • dns.lookup () car elle fait un appel à synchrone à getaddrinfo () ;
  • crypto.randomBytes () et crypto.pbkdf2 () ;
  • http.get () et http.request () si cela implique la résolution d'un nom de domaine.
Au passage, il convient de souligner l'importance d'un autre point que Bert Belder évoque aussi un peu trop rapidement alors qu'il est essentiel, c'est que l'implémentation de Node.js dépend du système d'exploitation. On s'en doute, dira-t-on : Linux et Windows n'ont pas les mêmes bibliothèques, ni le même kernel, par définition. Oui, mais cela implique autre chose, à savoir que la possibilité d'échapper au threading pour réaliser une opération peut varier selon le système d'exploitation. En conséquence, il faut souligner l'importance de cette remarque de Sam Roberts, qui base sa présentation sur l'implémentation de Node.js pour Linux :
It's also very difficult, if you are not really familiar with the underlying system calls, right now it's not documented in the Node API, how things are implemented, and it will matter to you eventually.
Autrement dit, pour vraiment optimiser l'utilisation de Node.js sur un système d'exploitation donné, il vaudrait mieux étudier le code de la version de Node.js correspondante ?
Et c'est ici qu'il s'avère que tout le monde a finalement droit à son SCUD. Enfin, merde!, comment veut-on que les gens comprennent une technologie si elle n'est pas correctement documentée ? Il n'y a pas que la bêtise de ceux qui parlent sans savoir qui est en question. Il y a aussi la bêtise de ceux qui leur en offre l'opportunité sur un plateau. Si la méconnaissance des mécanismes de base de Node.js pose autant de problèmes - au point qu'il faut consacrer des présentations à clarifier les choses lors de conférences consacrées aux usages avancés -, n'aurait-il pas convenu d'exposer ces mécanismes clairement dès le départ ?
Je dis que c'est rebelotte, du déjà-vu en trop d'occasions. Ainsi, c'est exactement ce qui s'est passé pour l'héritage en JavaScript, que le lambda a pris pour un héritage par classes alors que c'est un héritage par prototypes, parce cela n'a jamais été documenté clairement, alors que c'est juste la caractéristique fondamentale du langage. Et l'on en connaît la conséquence lamentable, à savoir que pour finir, les classes sont apparues sous forme de sucre syntaxique dans JavaScript, dénaturant complètement le langage.
Bref, la méconnaissance, c'est quelque chose que les concepteurs d'une technologie sèment en documentant mal cette technologie au départ, et qui leur revient dans la gueule en les contraignant à dénaturer cette technologie pour qu'elle reste populaire. Je dis que c'est pitoyable.

Pour conclure, Node.js en pratique, rapido

Terminons en reprenant les exemples donnés par Ryan Dahl dans sa présentation déjà mentionnée.
Node.js interdisant à un programme de bloquer le moteur, il est clair que ce dernier peut exécuter plusieurs programmes "en parallèle" : ce ne sont jamais que des événements qui se rajoutent dans la file. Toutefois, cela a un coût pour le développeur d'un programme, car il doit structurer son code sous la forme d'imbrications de callbacks, ce qui débouche assez rapidement sur le fameux "callback hell". Or il se trouve que cette écriture a été considérablement facilitée quelques années après la naissance de Node.js, avec l'apparition de l'objet Promise dan ECMAScript 2015, puis des instructions async et await dans ECMAScript 2017, innovations reprises dans JavaScript, fonctionnalités détaillées longuement par votre serviteur ici.
On comprend que si Node.js est utilisé sur une machine pour exécuter des scripts à la demande d'un serveur HTTP sur requête de clients, ce serveur va pouvoir traiter beaucoup plus de requêtes qu'un serveur classique, qui crée un thread par requête.
En effet, comme par définition l'exécution d'un script n'est jamais bloquante, le serveur n'est pas contraint de créer un thread par requête pour éviter d'être bloqué par le script exécuté pour traiter cette dernière. Partant, le serveur n'est pas limité par le nombre de threads que les ressources qui lui sont allouées lui permettent de créer. Et donc il ne se trouve jamais contraint de refuser le traitement d'une requête alors qu'il ne fait rien, pour la mauvaise raison qu'il ne peut plus créer de threads sur l'instant, alors même que certains de ses threads sont occupés à ne rien faire dans l'attente de la fin d'une opération quelconque - expiration d'un délai, écriture sur le disque, etc.
Un intérêt du package http est qu'il fonctionne en HTTP/1.1. Cela permet notamment au serveur de consommer encore moins de ressources, puisqu'il n'a pas à stocker le résultat d'une requête pour la renvoyer en une fois : il peut la renvoyer par morceaux sur une connexion persistante.
Dans ces conditions, une réponse contiendra les en-têtes suivants :
Connection: keep-alive
Transfer-Encoding: chunked
Très pratique si la réponse est volumineuse et peut être constituée progressivement, comme les enregistrements d'une table extraits par bloc d'une base de données.
Au passage, rappelons qu'une connexion persistante est une connexion TCP maintenue d'une requête à une autre, si bien que le client et le serveur peuvent s'épargner d'avoir à négocier l'ouverture d'une connexion : cela permet d'éviter de consommer des ressources, de perdre du temps, et d'engorger le réseau avec des paquets relatifs à l'ouverture. Aussi, cela permet de pipeliner les requêtes et les réponses, le fait qu'elles circulent via une seule et même connexion permettant aux parties d'en reconnaître l'ordre - la spécification HTTP/1.1 impose que sur une connexion persistante, le serveur renvoie des réponses dans l'ordre dans lequel il a reçu des requêtes.
A vrai dire, la lecture de la spécification de HTTP/1.1 révèle qu'une connexion est persistante par défaut depuis cette version du protocole. L'en-tête est un héritage de HTTP/1.0, où ce n'était pas le cas, et où le mécanisme pour établir une connexion persistante était différent et posait un problème au niveau du proxy. Pour la rétro-comptabilité, la spécification indique qu'il ne faut pas établir de connexion persistante HTTP/1.1 avec un client qui demande une connexion persistante HTTP/1.0. Bref, si le client et le serveur communique directement en HTTP/1.1, n'est-il pas possible de se passer de cet en-tête ? A voir...
Un autre package, "net", permet de créer une socket TCP. Par exemple, un fichier socket.js :
var net = require ('net');
var server = net.createServer (function (socket) {
	socket.write ('Hello world\n');
	socket.on ('data', function (data) {
		socket.write ('You sent: ' + data + '\n');
	});
});
server.listen (3000);
Donc tout simplement lancer le serveur en ligne de commandes... :
C:\Temp\node-v14.15.4-win-x64> node socket.js
...et y accéder via l'outil ncat de nmap :
C:\Temp\nmap-7.80> ncat.exe localhost 3000
Boucle d’événements et multithreading dans Node.js