Analyse comparative d'une promesse Node.js
Publié: 2022-03-11Nous vivons dans un nouveau monde courageux. Un monde rempli de JavaScript. Ces dernières années, JavaScript a dominé le Web, prenant d'assaut l'ensemble de l'industrie. Après l'introduction de Node.js, la communauté JavaScript a pu utiliser la simplicité et la dynamique du langage pour être le seul langage à tout faire, gérant le côté serveur, le côté client, et est même allé audacieusement et a revendiqué une position pour l'apprentissage automatique. Mais JavaScript a radicalement changé en tant que langage au cours des dernières années. De nouveaux concepts ont été introduits qui n'existaient pas auparavant, comme les fonctions fléchées et les promesses.
Ah, des promesses. Tout le concept d'une promesse et d'un rappel n'avait pas beaucoup de sens pour moi lorsque j'ai commencé à apprendre Node.js. J'étais habitué à la manière procédurale d'exécuter du code, mais avec le temps j'ai compris pourquoi c'était important.
Cela nous amène à la question, pourquoi les rappels et les promesses ont-ils été introduits de toute façon ? Pourquoi ne pouvons-nous pas simplement écrire du code exécuté séquentiellement en JavaScript ?
Eh bien, techniquement, vous pouvez. Mais devriez-vous?
Dans cet article, je vais donner une brève introduction sur JavaScript et son exécution, et plus important encore, tester la croyance largement répandue dans la communauté JavaScript selon laquelle le code synchrone est inférieur à la performance et, dans un sens, tout simplement mauvais, et ne devrait jamais être utilisé. Ce mythe est-il vraiment vrai ?
Avant de commencer, cet article suppose que vous êtes déjà familiarisé avec les promesses en JavaScript, cependant, si vous n'êtes pas ou avez besoin d'un rappel, veuillez consulter les promesses JavaScript : un didacticiel avec des exemples
NB Cet article a été testé sur un environnement Node.js, et non sur un environnement JavaScript pur. Exécution de Node.js version 10.14.2. Tous les benchmarks et la syntaxe s'appuieront fortement sur Node.js. Les tests ont été exécutés sur un MacBook Pro 2018 avec un processeur quadricœur Intel i5 de 8e génération exécutant une vitesse d'horloge de base de 2,3 GHz.
La boucle d'événements
Le problème avec l'écriture de JavaScript est que le langage lui-même est à thread unique. Cela signifie que vous ne pouvez pas exécuter plus d'une seule procédure à la fois contrairement à d'autres langages, tels que Go ou Ruby, qui ont la capacité de générer des threads et d'exécuter plusieurs procédures en même temps, soit sur les threads du noyau, soit sur les threads de processus. .
Pour exécuter du code, JavaScript s'appuie sur une procédure appelée la boucle d'événement qui est composée de plusieurs étapes. Le processus JavaScript passe par chaque étape, et à la fin, tout recommence. Vous pouvez en savoir plus sur les détails dans le guide officiel de node.js ici.
Mais JavaScript a quelque chose dans ses manches pour lutter contre le problème de blocage. Rappels d'E/S.
La plupart des cas d'utilisation réels qui nous obligent à créer un thread sont le fait que nous demandons une action dont le langage n'est pas responsable, par exemple, demander une récupération de certaines données de la base de données. Dans les langages multithreads, le thread qui a créé la requête se bloque simplement ou attend la réponse de la base de données. Ce n'est qu'un gaspillage de ressources. Cela oblige également le développeur à choisir le nombre correct de threads dans un pool de threads. Ceci afin d'éviter les fuites de mémoire et l'allocation de beaucoup de ressources lorsque l'application est très sollicitée.
JavaScript excelle dans une chose plus que tout autre facteur, la gestion des opérations d'E/S. JavaScript vous permet d'appeler une opération d'E/S telle que demander des données à une base de données, lire un fichier en mémoire, écrire un fichier sur le disque, exécuter une commande shell, etc. Lorsque l'opération est terminée, vous exécutez un rappel. Ou en cas de promesses, vous résolvez la promesse avec le résultat ou la rejetez avec une erreur.
La communauté JavaScript nous conseille toujours de ne jamais utiliser de code synchrone lors des opérations d'E/S. La raison bien connue en est que nous ne voulons PAS empêcher notre code d'exécuter d'autres tâches. Comme il s'agit d'un seul thread, si nous avons un morceau de code qui lit un fichier de manière synchrone, le code bloquera tout le processus jusqu'à ce que la lecture soit terminée. Au lieu de cela, si nous nous appuyons sur du code asynchrone, nous pouvons effectuer plusieurs opérations d'E/S et gérer la réponse de chaque opération individuellement lorsqu'elle est terminée. Aucun blocage.
Mais sûrement, dans un environnement où nous ne nous soucions pas du tout de gérer un grand nombre de processus, l'utilisation de code synchrone et asynchrone ne fait aucune différence, n'est-ce pas ?
Référence
Le test que nous allons effectuer visera à nous fournir des repères sur la vitesse d'exécution du code de synchronisation et asynchrone et s'il existe une différence de performances.
J'ai décidé de choisir la lecture d'un fichier comme opération d'E/S à tester.
Tout d'abord, j'ai écrit une fonction qui écrira un fichier aléatoire rempli d'octets aléatoires générés avec le module Node.js Crypto.
const fs = require('fs'); const crypto = require('crypto'); fs.writeFileSync( "./test.txt", crypto.randomBytes(2048).toString('base64') )
Ce fichier agirait comme une constante pour notre prochaine étape qui consiste à lire le fichier. Voici le code
const fs = require('fs'); process.on('unhandledRejection', (err)=>{ console.error(err); }) function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); await Promise.all([p0]) console.timeEnd("async") } synchronous() asynchronous()
L'exécution du code précédent a donné les résultats suivants :
Cours # | Synchroniser | Asynchrone | Rapport asynchrone/synchrone |
---|---|---|---|
1 | 0,278 ms | 3.829ms | 13.773 |
2 | 0,335 ms | 3.801ms | 11.346 |
3 | 0,403 ms | 4.498ms | 11.161 |
C'était inattendu. Mes attentes initiales étaient qu'ils devraient prendre le même temps. Eh bien, que diriez-vous d'ajouter un autre fichier et de lire 2 fichiers au lieu de 1 ?

J'ai répliqué le fichier généré test.txt et l'ai appelé test2.txt. Voici le code mis à jour :
function synchronous() { console.time("sync"); fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") console.timeEnd("sync") } async function asynchronous() { console.time("async"); let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); await Promise.all([p0,p1]) console.timeEnd("async") }
J'ai simplement ajouté une autre lecture pour chacune d'entre elles, et dans les promesses, j'attendais les promesses de lecture qui devaient s'exécuter en parallèle. Voici les résultats :
Cours # | Synchroniser | Asynchrone | Rapport asynchrone/synchrone |
---|---|---|---|
1 | 1.659ms | 6.895ms | 4.156 |
2 | 0,323 ms | 4.048ms | 12.533 |
3 | 0,324 ms | 4.017ms | 12.398 |
4 | 0,333 ms | 4.271ms | 12.826 |
Le premier a des valeurs complètement différentes des 3 runs qui suivent. Je suppose que cela est lié au compilateur JavaScript JIT qui optimise le code à chaque exécution.
Donc, les choses ne se présentent pas si bien pour les fonctions asynchrones. Peut-être que si nous rendons les choses plus dynamiques et que nous insistons un peu plus sur l'application, nous pourrions obtenir un résultat différent.
Mon prochain test consiste donc à écrire 100 fichiers différents, puis à les lire tous.
Tout d'abord, j'ai modifié le code pour écrire 100 fichiers avant l'exécution du test. Les fichiers sont différents à chaque exécution, bien que conservant presque la même taille, nous effaçons donc les anciens fichiers avant chaque exécution.
Voici le code mis à jour :
let filePaths = []; function writeFile() { let filePath = `./files/${crypto.randomBytes(6).toString('hex')}.txt` fs.writeFileSync( filePath, crypto.randomBytes(2048).toString('base64') ) filePaths.push(filePath); } function synchronous() { console.time("sync"); /* fs.readFileSync("./test.txt") fs.readFileSync("./test2.txt") */ filePaths.forEach((filePath)=>{ fs.readFileSync(filePath) }) console.timeEnd("sync") } async function asynchronous() { console.time("async"); /* let p0 = fs.promises.readFile("./test.txt"); let p1 = fs.promises.readFile("./test2.txt"); */ // await Promise.all([p0,p1]) let promiseArray = []; filePaths.forEach((filePath)=>{ promiseArray.push(fs.promises.readFile(filePath)) }) await Promise.all(promiseArray) console.timeEnd("async") }
Et pour le nettoyage et l'exécution :
let oldFiles = fs.readdirSync("./files") oldFiles.forEach((file)=>{ fs.unlinkSync("./files/"+file) }) if (!fs.existsSync("./files")){ fs.mkdirSync("./files") } for (let index = 0; index < 100; index++) { writeFile() } synchronous() asynchronous()
Et courons.
Voici le tableau des résultats :
Cours # | Synchroniser | Asynchrone | Rapport asynchrone/synchrone |
---|---|---|---|
1 | 4.999ms | 12.890ms | 2.579 |
2 | 5.077ms | 16.267ms | 3.204 |
3 | 5.241ms | 14.571ms | 2.780 |
4 | 5.086ms | 16.334ms | 3.213 |
Ces résultats commencent à tirer une conclusion ici. Cela indique qu'avec l'augmentation de la demande ou de la simultanéité, les promesses de frais généraux commencent à avoir un sens. Pour l'élaboration, si nous exécutons un serveur Web censé exécuter des centaines, voire des milliers de requêtes par seconde et par serveur, l'exécution d'opérations d'E/S à l'aide de la synchronisation commencera à perdre ses avantages assez rapidement.
Juste pour le plaisir de l'expérimentation, voyons si c'est réellement un problème avec les promesses elles-mêmes ou s'il s'agit d'autre chose. Pour cela, j'ai écrit une fonction qui va calculer le temps pour résoudre une promesse qui ne fait absolument rien et une autre qui résout 100 promesses vides.
Voici le code :
function promiseRun() { console.time("promise run"); return new Promise((resolve)=>resolve()) .then(()=>console.timeEnd("promise run")) } function hunderedPromiseRuns() { let promiseArray = []; console.time("100 promises") for(let i = 0; i < 100; i++) { promiseArray.push(new Promise((resolve)=>resolve())) } return Promise.all(promiseArray).then(()=>console.timeEnd("100 promises")) } promiseRun() hunderedPromiseRuns()
Cours # | Promesse unique | 100 promesses |
---|---|---|
1 | 1.651ms | 3.293ms |
2 | 0,758 ms | 2.575ms |
3 | 0,814 ms | 3.127ms |
4 | 0,788 ms | 2.623ms |
Intéressant. Il semble que les promesses ne soient pas la cause principale du retard, ce qui me fait supposer que la source du retard est les threads du noyau effectuant la lecture réelle. Cela pourrait prendre un peu plus d'expérimentation pour arriver à une conclusion décisive sur la principale raison du retard.
Un dernier mot
Alors, devriez-vous utiliser des promesses ou non ? Mon avis serait le suivant :
Si vous écrivez un script qui s'exécutera sur une seule machine avec un flux spécifique déclenché par un pipeline ou un seul utilisateur, optez pour le code de synchronisation. Si vous écrivez un serveur Web qui sera chargé de gérer beaucoup de trafic et de requêtes, la surcharge résultant de l'exécution asynchrone dépassera les performances du code de synchronisation.
Vous pouvez trouver le code de toutes les fonctions de cet article dans le référentiel.
La prochaine étape logique de votre parcours de développeur JavaScript, à partir des promesses, est la syntaxe async/wait. Si vous souhaitez en savoir plus à ce sujet et sur la manière dont nous en sommes arrivés là, consultez JavaScript asynchrone : de Callback Hell à Async et Await .