Sommaire
- Pourquoi c'est bien : l'ergonomie et la fiabilité
- Pourquoi c'est bien : les performances
- Oui mais le parallélisme ?
- Oui mais les inconvénients ?
- Et quoi d'autre ?
Node.js utilise le pattern / modèle de concurrence Reactor, ou plus simplement mais moins précisément celui de boucle d'événement.
D'un point de vue concret, ça se traduit par :
- L'exécution du programme principal dans un thread unique
- La (quasi) totalité des fonctions d'I/O qui sont non bloquantes
- Une boucle d'événements qui démultiplexe les I/O entrantes et appelle les callbacks ad-hoc
Et du point de vue du programmeur, ça se traduit de la façon suivante :
import { readFile } from "node:fs";
readFile("toto.txt", (err, data) => {
console.log(data);
});
Ce qui n'est pas très pratique : à moins d'utiliser une abstraction idoine, le séquencement du programme est difficle à faire. Par exemple, si on veut lire deux fichiers de façon séquentielle, on écrirait :
readFile("toto.txt", (err, toto) => {
readFile("titi.txt", (err, titi) => {
console.log(toto, titi);
});
});
Ce qui, vous le conviendrez, est déjà très moche avec seulement deux opérations.
Ce problème est amélioré grâce à un objet qui s'appelle, une promesse, et qui comme son nom l'indique, contient la promesse d'une valeur, et qui permet d'indiquer les opérations ultérieures à faire sur la future valeur, au même titre qu'un tableau muni de la fonction flatMap permet de spécifier des operations à faire sur une valeur du tableau (on appelle ça une monade).
Avec un exemple de code, c'est beaucoup plus simple :
import { readFile } from "node:fs/promises";
const totoPromise = readFile("toto.txt");
const titiPromise = totoPromise.then((toto) => readFile("titi.txt"));
Promise.all([totoPromise, titiPromise]).then(([toto, titi]) =>
console.log(toto, titi),
);
On n'a plus le problème des callbacks l'un dans l'autre (appelé callback hell), mais ça reste assez verbeux, et pour exprimer que l'on souhaite travailler sur la promesse de toto et titi, on doit utiliser la fonction Promise.all.
Pour améliorer ça, on utilise un sucre syntaxique qui s'appelle async/await : le mot clé await peut être utilisé pour "extraire" la valeur d'une promesse. Ce mot clé ne peut être utilisé que dans une fonction déclarée async, et qui retourne toujours une promesse, ou à la racine du programme.
Ainsi, notre programme s'écrit de cette façon :
const toto = await readFile("toto.txt");
const titi = await readFile("titi.txt");
console.log(toto, titi);
Ce programme ressemble à un programme écrit dans un langage avec de l'I/O bloquante, mais ce n'est que du sucre syntaxique : l'exécution est en pratique identique à notre premier exemple avec des callbacks, ce qui signifie notamment que notre programme est toujours contrôlé par la boucle d'événements, et que notre thread unique peut exécuter d'autres ligne de code en attendant que le noyau nous informe que le contenu du fichier toto.txt ou titi.txt est arrivé.
Pourquoi c'est bien : l'ergonomie et la fiabilité
Niveau ergonomie, on a déjà vu que pour des cas simples, avec async/await, ce n'est presque pas plus compliqué que de faire de l'I/O bloquante.
Là où le gain est beaucoup plus important est que, vu que l'on n'a qu'un thread, il n'y a pas besoin d'utiliser de synchronisation entre threads. Par exemple créons un serveur TCP qui incrémente un compteur à chaque fois qu'un paquet est reçu :
import { createServer } from "node:net";
let i = 0;
createServer((socket) => {
socket.on("data", (data) => {
i++;
});
}).listen(4242);
Ce code est parfaitement sûr, alors que dans un langage où on traiterait les requêtes dans un pool de threads il faudrait verrouiller les accès à la variable i, ou utiliser des opérations atomiques (d'ailleurs, en C et en C++, incrémenter une variable n'est pas atomique).
L'autre avantage, en terme d'ergonomie, est qu'il est très facile de gérer la concurrence de façon fine. Par exemple, si l'on souhaite lire les fichiers toto et titi de façon concurrente on peut faire :
const totoPromise = readFile("toto.txt"); // L'exécution commence dès qu'on appelle readFile. readFile est non-bloquant, donc la ligne ci-dessous est exécutée immédiatement
const titiPromise = readFile("titi.txt");
console.log(await toto, await titi);
Ou, de façon plus idiomatique :
const [toto, titi] = await Promise.all([
readFile("toto.txt"),
readFile("titi.txt"),
]);
console.log(await toto, await titi);
Ou de façon encore plus concise :
const fnames = ["toto.txt", "titi.txt"];
const files = await Promise.all(fnames.map((fn) => readFile(fn)));
console.log(...files);
Pourquoi c'est bien : les performances
Il y a plusieurs raisons pourquoi, pour du code faisant beaucoup d'I/O, Node.js est très performant.
Le premier point, qu'on a déjà vu, est qu'il n'y a pas besoin de synchronisation. Dans un cas où il y a beaucoup de contention, et plus la machine a de coeurs et une architecture mémoire non uniforme, plus la synchronisation peut être très lente. Typiquement l'acquisition d'un mutex peut prendre, par exemple en Go, plusieurs centaines de nano-secondes, soit le temps qu'un thread unique aurait utilisé pour exécuter plusieurs centaines d'instructions.
Le 2e point, est que la concurrence n'a quasiment aucun surcoût mémoire. Par exemple, lorsqu'une socket tcp est ouverte, les coûts mémoire sont la socket dans le noyau, ainsi que les éventuels callbacks qui y sont attachés. Un langage qui utilise un green thread par socket, voire pire une thread par socket (ce qui d'ailleurs ne se fait pas vu le coût) aurait aussi le coût du thread, en particulier celui de la pile. Cela rend Node.js particulièrement adapté à la gestion de connexions persistantes, par exemple de websockets.
Oui mais le parallélisme ?
Un thread, c'est bien, mais comment faire davantage de traitements simultanés ou des calculs lourds sans bloquer le thread principal ?
Il y a principalement deux options :
- le scaling horizontal, typiquement pour des serveurs web : on lance autant de serveur qu'on a de processeurs. Cela peut se faire à l'aide de fonctions intégrées à node (typiquement le module cluster), d'outils liés à l'écosystème node (pm2), ou d'outils génériques (k8s, etc.)
- exécuter le travail dans un autre thread, et le récupérer de façon asynchrone. C'est le cas de certaines fonctions de la librairie standard (module crypto), peut se faire en C++ (sur le même mécanisme que crypto) ou en javascript (worker_threads)
Oui mais les inconvénients ?
Le principal inconvénient est que tout traitement lent bloque complètement le thread, ce qui rend difficile de maitriser la latence en queue de distribution. Ça peut par exemple être le cas d'un serveur web qui reçoit régulièrement des grosses requêtes longues à désérialiser. Sur d'autres modèles d'exécution le traitement lent serait préempté, ce qui nuirait moins à la latence.
Et quoi d'autre ?
J'ai essayer de rester bref, mais il y a plein d'autre intéressantes sur la concurrence en général (gestion de la concurrence, des files d'attente, etc.), et d'autres thèmes qui se prêtent bien au traitement asynchrone (streams, etc.)
Un autre point est que, bien que JavaScript se prête a priori assez mal à l'optimisation, l'optimiseur de V8 (le runtime de Node.js) est très performant, et du code qui n'utilise pas d'antipatterns de performance peut s'exécuter à des vitesses quasi-natives.
Envoyer un commentaire
Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.