Opérations non-bloquantes et utilisation de setImmediate

Ayant été un grand non-sympathisant (pour ne pas dire opposant/rageux) de JavaScript, je n’ai jamais vraiment pris le temps de m’atteler à Node ni les concepts qu’il propose. Du coup, je préviens tout de suite que ces billets risquent de m’être plus utiles qu’à ceux qui les liront, enfonçant des portes ouvertes pour beaucoup d’entre-vous, certainement.

Je me suis donc frotté aux notions d'event loop et d'asynchrone, non sans accrocs. :D

Double standard

Quand j’ai commencé à m’intéresser aux fondements de Node, donc, la documentation m’a expliqué ceci:

JavaScript execution in Node.js is single threaded, so concurrency refers to the event loop’s capacity to execute JavaScript callback functions after completing other work. Any code that is expected to run in a concurrent manner must allow the event loop to continue running as non-JavaScript operations, like I/O, are occurring.

Ok, mais même si ici on parle bien d’exploiter l'event loop, j’avais toujours un problème pour comprendre pourquoi toutes les opérations d’I/O supportées par Node pouvaient être asynchrones alors que mes opérations (de simples boucles, par exemple) ne l’étaient absolument pas malgré l’utilisation de callback.

Cela pourrait paraître clair pour d’autres, mais la documentation ne faisant référence qu’aux callback et à l’async sans réellement rentrer dans les détails, j’ai fini par croire que les callbacks étaient la cause plutôt que le moyen de faire de l’asynchrone. L’event loop est remplie de callback/task, mais ce n’est pas l’utilisation de callback qui remplit l’event loop. :euh:

A queue to rule them all

Après quelques moments passés à chercher ce qui pouvait bien flancher dans mon raisonnement, je suis retombé sur le célèbre service setTimeout, assez fréquemment utilisé pour commettre les pires atrocités sémantiques que j’aie pu voir dans le développement frontend. Je savais que cela permettait de mettre en arrière-plan des tâches, mais n’étant pas développeur web/js, je m’en suis toujours servi plus ou moins à l’aveugle. Cependant, je me suis attelé à comprendre son fonctionnement (certes basique) pour l’utiliser correctement et aviser en cas d’erreur de ma part.

// Inutile de s'attarder sur les 5000 ms
// ça aurait pu être n'importe quelle valeur
// (0 par exemple).
router.get('/', function(req, res, next) {
  const text = new Promise((resolve, reject) => {
    setTimeout((message) => {
        var i = 0;
        while(i < 50000) {
            i++;
        }
        resolve(message);
    }, 5000, 'Done!');
  })
  .then(console.log)
  .catch(console.err);
  res.send('Hi there!');
});

module.exports = router;

Enfin un résultat positif ! La boucle tourne en arrière-plan et ne bloque plus l’envoi de la page au client ni les opérations adjacentes. Là, pour moi, le fonctionnement est clair. Je sais désormais que, lorsque j’ai affaire à une opération bloquante telle que celle-ci (ou une autre), je suis capable de l’enregistrer dans l’event loop par le biais de setTimeout et d’en récupérer le renvoi grâce à Promise. Génial ! :)

Et setImmediate dans tout ça ?

Une fois la notion assimilée, je suis tombé sur un post de stackoverflow (il me semble) discutant d’un service identique à setTimeout et setInterval (dans le fonctionnement): setImmediate().

Sa particularité serait de remplacer le commun setTimeout(0) (i.e. envoi immédiat d’une tâche en arrière-plan) par son appel tout en améliorant les performances et réduisant la consommation d’électricité de cette mise en tâche de fond.

router.get('/', function(req, res, next) {
  const heavy_processing = new Promise((resolve, reject) => {
    setImmediate((message) => {
        var i = 0;
        while(i < 50000) {
            i++;
        }
        resolve(message);
    }, 'Done!');
  })
  .then(console.log)
  .catch(console.err);
  res.send('Hi there!');
});

module.exports = router;

Les modifications sont très minimes, il suffit de supprimer le paramètre milliseconds de la fonction d’origine pour ne pas avoir de soucis.


Voilà, c’est tout !

Rien d’extraordinaire en soit, mais j’avais vraiment besoin de l’écrire pour garder tout ça en tête. C’était jusqu’ici l’une des seules choses que je ne parvenais pas à saisir et je ne me serais pas senti à l’aise de poursuivre ma découverte sans bien comprendre les fondamentaux. A l’inverse, si un (ou plusieurs) maître jedi JavaScript passe par ici et souhaite corriger certaines inexactitudes, je suis absolument preneur !

8 commentaires

Un autre truc que j’ai eu du mal à piger, surtout après avoir fait de l’Android dans lequel on utilise massivement les threads, c’est ça :

JavaScript execution in Node.js is single threaded,

En fait c’est aussi le cas dans le navigateur, pas que dans Node.js. L’une des conséquences, c’est que si le traitement est long parce que consommateur en CPU1, se contenter de Promise / callbacks / asyncawait et de l’event loop t’envoie quand même dans les choux. Parce que d’accord les bouts de code peuvent s’exécuter plus ou moins en même temps, mais ils ne s’exécutent quand même que sur un thread.

En fait tout ça permet de faire des opérations non bloquantes, mais absolument pas du vrai parallélisme.

Pour faire du vrai parallélisme dans un navigateur (je ne sais pas faire sous NodeJS, je ne développe pas avec ça), il faut sortir les Web Workers (idéalement en pool dès qu’on en a besoin de plus qu’un), avec tout ce que ça implique. En particulier qu’ils ont leur propre contexte d’exécution, qu’ils n’ont pas accès aux « objets standard du navigateur », et qu’on ne peut pas leur passer n’importe quoi.


  1. Au hasard la génération de PDF.

En fait tout ça permet de faire des opérations non bloquantes, mais absolument pas du vrai parallélisme.

C’est toute la subtilité de la différence entre concurrence et parallélisme, qui donne également du fil à retordre aux pythonistes qui découvrent asyncio.

Le paradigme asynchrone est puissant, car il vient avec des outils (les promesses/futures/tâches/coroutines…) qui permettent d’écrire du code raisonnablement simple pour faire des choses parfois très compliquées. Par exemple, il m’est arrivé récemment au boulot d’écrire un simple décorateur @share_lock qui permet de dire : "si plusieurs fils d’exécution appellent simultanément cette coroutine avec les mêmes paramètres, alors seul le premier arrivé va réellement exécuter le code, et tous les autres vont se contenter d’attendre que l’appel soit terminé et en récupérer le résultat de façon totalement transparente", et ce en à peine dix lignes.

C’est particulièrement génial parce que ce modèle de concurrence est beaucoup plus facile à utiliser et à débugger que les threads (les risques de races sont très amoindris quand on sait que telle partie du code, qui ne cède jamais explicitement la main, est donc forcément atomique).

Par contre, cela nécessite quand même une certaine rigueur : même si les races sont plus rares, il faut quand même constamment se demander ce qui se passe quand la tâche qu’on est en train de coder sera annulée. En clair, on sait quand, très précisément, cela peut arriver dans le code, mais il faut quand même se rappeler que si ça le peut, alors ça le sera fatalement un jour.

Enfin, comme ru le rappelles si bien, il faut également des connaissances et une intuition assez solides pour déterminer quand ça vaut le coup de lancer des exécutions concurrentes, et quand cela rajoute inutilement de la complexité au code.

Bref, c’est un paradigme puissant, mais qui a également le pouvoir de transformer un code qui marche en un tas de boue over-engineered et beaucoup plus difficile à suivre/débugger que nécessaire si l’on ne comprend pas ce qui se passe dans les coulisses et qu’on en colle à toutes les sauces.

+0 -0

Salut tout l’monde,

Pour faire du vrai parallélisme dans un navigateur (je ne sais pas faire sous NodeJS, je ne développe pas avec ça), il faut sortir les Web Workers (idéalement en pool dès qu’on en a besoin de plus qu’un), avec tout ce que ça implique. En particulier qu’ils ont leur propre contexte d’exécution, qu’ils n’ont pas accès aux « objets standard du navigateur », et qu’on ne peut pas leur passer n’importe quoi.

SpaceFox

D’après mes modestes connaissances à ce sujet, il faudrait littéralement créer un nouveau processus comme suit.

 ls = childProcess.exec('node ton_script.js', function (error, stdout, stderr) {
    /* ... */
 });

Ca reste quand même vachement overkill à mon goût. Du moins, pas plus light que du multithread dans ce cas. J’espère très fort que WebAssembly deviendra rapidement mature pour combler ce genre de lacunes (concernant les performances, pas la gestion des threads/processus).

Enfin, comme ru le rappelles si bien, il faut également des connaissances et une intuition assez solides pour déterminer quand ça vaut le coup de lancer des exécutions concurrentes, et quand cela rajoute inutilement de la complexité au code.

Des performances ça se mesure avant d’optimiser au doigt mouillé. Il vaut mieux d’abord coder ce qui rend le code plus simple à la compréhension/lecture/écriture, puis vérifier ce qui a besoin d’être optimisé, et enfin prendre la peine de faire cette optimisation (et vérifier que ça optimise effectivement, parce que c’est toujours de la magie vodoo sinon).

+1 -0

Tiens c’est marrant, je pensais à écrire quelque chose sur l’asynchrone hier pendant la rédaction d’un rapport qui en parlait.

enfonçant des portes ouvertes pour beaucoup d’entre-vous, certainement.

Si l’asynchrone était simple, tout le monde en ferait. :D Cf tous les talks qui ont été fait entre 2014 et 2017 là dessus, qui reviennent encore avec rust pour dire à peu près la même chose en soulignant que ça corrige les problèmes évoqués par Nohar. Cf aussi l’utilisation quasi inexistante des IOCP sous Windows, que même nginx n’utilise pas alors qu’il s’agit de la première implémentation performante (septembre 1994), en modèle par complétion, de la famille epoll/kqueue/iocp.

En règle général, quand tu vois des bibliothèques comme libevent ou les patterns qui reviennent pour la programmation asynchrone (pattern proactor comme dans boost:asio) ou même juste quand tu essayes de comprendre comment await/async fonctionne en pratique et en coulisse, tu te rends compte que ça garde un coût non négligeable de complexité.

Une question que je me posais récemment, c’est de savoir si les promesses étaient un pattern acceptable sous windows dans un langage bas niveau. Elles viennent avec un mutex et une variable de condition et sous Linux c’est principalement du futex donc on ne paye pas vraiment le coût de la promesse, or il me semblait que windows n’avait pas de futex. Mais à priori d’autres mécanismes permettraient de faire ça, comme https://blogs.msdn.microsoft.com/oldnewthing/20170601–00/?p=96265 Du coup je me demande toujours si faire des promesses partout seraient pas un meilleur moyen de faire de l’asynchrone dans tous les programmes.

+0 -0

Enfin, comme ru le rappelles si bien, il faut également des connaissances et une intuition assez solides pour déterminer quand ça vaut le coup de lancer des exécutions concurrentes, et quand cela rajoute inutilement de la complexité au code.

Des performances ça se mesure avant d’optimiser au doigt mouillé. Il vaut mieux d’abord coder ce qui rend le code plus simple à la compréhension/lecture/écriture, puis vérifier ce qui a besoin d’être optimisé, et enfin prendre la peine de faire cette optimisation (et vérifier que ça optimise effectivement, parce que c’est toujours de la magie vodoo sinon).

germinolegrand

On est bien d’accord, mais c’est complètement orthogonal à ce que j’ai dit : comment décider si tu vas faire ton optimisation avec des processus fils, ou un pool de threads, ou de l’asynchrone, ou toute combinaison entre ces trois là ? C’est clairement pas ton profileur qui te le dira…

Et encore, dans l’idéal il faut même avoir ces connaissances pour comprendre ce qu’a vraiment mesuré ton profileur, et donc ce qu’il te dit, et avoir assez d’intuition pour simplement être capable de manipuler toutes ces notions mentalement. C’est de ça que je parle.

+2 -0

Tiens c’est marrant, je pensais à écrire quelque chose sur l’asynchrone hier pendant la rédaction d’un rapport qui en parlait.

J’avais écrit un truc sur ZdS à une époque justement le sujet asyncio/nio.

En fait tout ça permet de faire des opérations non bloquantes, mais absolument pas du vrai parallélisme.

Tout n’est pas tout à fait pareil. En fait, tout le danger est de confondre une API reactive (Future, Promise, …) et son contexte d’exécution.

Certaines opérations bloquantes (notamment les I/O) peuvent être remplacées par leur équivalent asynchrone. readFile etc. Là c’est du "vrai" async. On a une event-loop, et c’est vraiment une callback : "quand l’OS aura fini de lire le fichier, tu me rappelleras, en attendant, fous-moi la paix et passe à autre chose".

Par contre, "wrapper" un bout de code au sein d’un bricolage d’API (async, …) ne le rend pas par magie asynchrone… Et une façon très simple de rendre une opération bloquante, non-bloquante, c’est effectivement de déléguer son exécution à un thread hors event-loop (notion de "worker-thread"). Mais déjà, toutes les plateformes ne le permettent pas, et ensuite, c’est délicat : d’une part, il faut que ça en vaille la peine (c’est coûteux pour une JVM d’ouvrir un thread et le manager) d’autre part, même si on a l’impression qu’on peut ouvrir autant de threads qu’on veut, l’OS a ses limites…

Pour le coup je te conseille les premières lignes de la doc de Vert.x (vertx.io) qui expliquent ce concept très très bien.

Un autre truc qui aide beaucoup à prendre conscience de cette distinction de "l’API que j’appelle a l’air asynchrone" vs. "il se passe quoi réellement sous le capot pour que ce soit asynchrone", c’est l'ExecutionContext de scala : dès que tu manipules un objet Future (notamment, dès que tu en crées un : Future("hello")), le code ne compile pas si tu ne lui as pas fourni, implicitement, un contexte d’exécution. Par défaut (scala.concurrent.ExecutionContext) il se content de déléguer l’exécution du Future à un autre thread. Mais si tu utilises une bibliothèque genre Akka, ce n’est pas la même limonade.

Et quand tu passes aux ©(g)(k)oroutines, repérer ce genre de trucs devient encore plus complexe, puisque le code est fait pour être écrit "comme si tout était synchrone".

+1 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte