On dit souvent que les méthodes asynchrones apportent à Node.JS un avantage dans sa gestion des accès concurrents. On dit que pour une opération aussi coûteuse que de la lecture sur le disque par exemple, il saura gérer plus de connexions, et dans l’ensemble répondre plus vite si cette opération est faite en utilisant l’API asynchrone plutôt que l’API synchrone.
OK. Soit. En ce qui me concerne je n’ai pas le niveau technique pour comprendre les tenants et les aboutissants de tout ça, donc comment m’en convaincre? Et bien, benchmarkons 🙂
Partons d’un serveur HTTP très simple: il n’a qu’une page, qui affiche « coucou gamin » dès que le serveur a fini de lire un fichier local de quelques méga-octets.
Dans la suite de l’article je vais vous proposer 3 implémentations de ce serveur, et les résultats d’un test par Apache Bench.
Version asynchrone
Dans cette version on lit le fichier en utilisant « fs.readFile()« , et on ne répond que lorsque le fichier a été lu.
const fs = require('fs'); require('http').createServer(function (req, res) { fs.readFile('/path/to/file', function(err, content) { var status = err ? 500 : 200; res.writeHead(status, {"Content-Type": "text/plain"}); res.end("Coucou gamin"); }); }).listen(8081); |
On exécute ApacheBench sur le port 8081 avec 1000 requêtes et 100 accès concurrents: ab -n 1000 -c 100 http://localhost:8081/
Et voici le résultat:
Concurrency Level: 100 Time taken for tests: 14.054 seconds Complete requests: 1000 Failed requests: 0 Write errors: 0 Total transferred: 76000 bytes HTML transferred: 12000 bytes Requests per second: 71.15 [#/sec] (mean) Time per request: 1405.418 [ms] (mean) Time per request: 14.054 [ms] (mean, across all concurrent requests) Transfer rate: 5.28 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.7 0 3 Processing: 744 1366 113.4 1382 1745 Waiting: 744 1366 113.4 1382 1745 Total: 746 1367 112.9 1382 1745 Percentage of the requests served within a certain time (ms) 50% 1382 66% 1394 75% 1402 80% 1409 90% 1431 95% 1462 98% 1536 99% 1633 100% 1745 (longest request) |
Version synchrone
Dans cette version on lit le fichier en utilisant « fs.readFileSync()« .
const fs = require('fs'); require('http').createServer(function (req, res) { var status, content; try { content = fs.readFileSync('/path/to/file'); status = 200; } catch (err) { status = 500; } res.writeHead(status, {"Content-Type": "text/plain"}); res.end("Coucou gamin"); }).listen(8082); |
On effectue le même test: ab -n 1000 -c 100 http://localhost:8082/
Et voici le résultat:
Concurrency Level: 100 Time taken for tests: 37.742 seconds Complete requests: 1000 Failed requests: 0 Write errors: 0 Total transferred: 76000 bytes HTML transferred: 12000 bytes Requests per second: 26.50 [#/sec] (mean) Time per request: 3774.162 [ms] (mean) Time per request: 37.747 [ms] (mean, across all concurrent requests) Transfer rate: 1.97 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 1 1.6 0 9 Processing: 43 3583 2063.0 3525 7598 Waiting: 42 3583 2063.0 3525 7598 Total: 43 3584 2062.5 3525 7598 Percentage of the requests served within a certain time (ms) 50% 3525 66% 4817 75% 5073 80% 5486 90% 6502 95% 7032 98% 7357 99% 7450 100% 7598 (longest request) |
Fausse version asynchrone
Pour tricher, on va prendre le code de la version synchrone, et on le colle dans un « setTimeout(…, 0) ». De cette manière on a bien un code qui rend la main tout de suite, mais qui néanmoins se base sur l’API synchrone. J’ai déjà vu ce type de « tricherie » qui donne l’impression qu’on a du code asynchrone, donc pour être tout-à-fait exhaustif je tenais à le tester.
const fs = require('fs'); require('http').createServer(function (req, res) { setTimeout(function() { var status, content; try { content = fs.readFileSync('/path/to/file'); status = 200; } catch (err) { status = 500; } res.writeHead(status, {"Content-Type": "text/plain"}); res.end("Coucou gamin"); }, 0); }).listen(8083); |
On effectue le même test: ab -n 1000 -c 100 http://localhost:8083/
Et voici le résultat:
Concurrency Level: 100 Time taken for tests: 36.903 seconds Complete requests: 1000 Failed requests: 0 Write errors: 0 Total transferred: 76000 bytes HTML transferred: 12000 bytes Requests per second: 27.10 [#/sec] (mean) Time per request: 3690.269 [ms] (mean) Time per request: 36.903 [ms] (mean, across all concurrent requests) Transfer rate: 2.01 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.8 0 4 Processing: 48 3507 2034.4 3469 7435 Waiting: 48 3507 2034.4 3468 7435 Total: 49 3508 2034.2 3469 7435 Percentage of the requests served within a certain time (ms) 50% 3469 66% 4441 75% 5170 80% 5594 90% 6465 95% 6931 98% 7209 99% 7303 100% 7435 (longest request) |
À noter car c’est intéressant: j’ai refait plusieurs fois ce test en changeant la valeur dans setTimeout. Il n’y a quasiment aucune différence qu’on aille de 1 à 5ms d’attente. On commence à voir une différence avec 10ms, ou on perd 2 requêtes par seconde pour tomber à 25.
Conclusion
Mode de programmation | Req/s | Time/req |
---|---|---|
Asynchrone | 71.15 (100%) | 14.054 (100%) |
Synchrone | 26.50 (37%) | 37,742 (268%) |
Faux asynchrone | 27.10 (38%) | 36,903 (262%) |
En terme de performances, on parle donc bien dans notre cas d’un rapport 1 à 3. Ce n’est pas juste 10% de différence non, le serveur sera 3 fois plus performant dans le cas de l’asynchrone. Et pas la peine de tricher avec setTimeout()!
Pour information, j’ai fait ce test avec de nombreuses combinaisons de nombre de requêtes et d’accès concurrents (je ne sais pas trop comment sortir des graphes, et là j’ai la flemme :P). Comme on pourrait s’y attendre, en réduisant le nombre d’accès concurrents, on réduit l’écart. Mais ne vous y trompez pas: avec 1 seul accès simultané, j’observe toujours un rapport de 1 à 2 (en moyenne 17ms/req contre 37ms/req).
Conclusion: oubliez les méthodes de l’API Node.JS qui finissent par « Sync ». Non, vraiment, oubliez-les! Merci d’avance 🙂 En plus, si c’est de la soupe de callbacks dont vous avez peur, il y a de quoi se rassurer 😉
« En ce qui me concerne je n’ai pas le niveau technique pour comprendre les tenants et les aboutissants de tout ça, donc comment m’en convaincre? Et bien, benchmarkons 🙂 »
=> Même si le benchmark est bien réalisé, ce n’est pas une preuve.
Je conseille vivement de regarder les présentations de Ryan Dahl (le créateur) sur Node.js. Les vidéos se trouvent facilement. Elles sont en anglais, mais valent vraiment le coup pour comprendre pourquoi les I/O asynchrones permettent de meilleures performances.
Non mais bien sûr que je me suis un minimum renseigné. Néanmoins ce n’est pas parce qu’on me dit « regarde ça va plus vite parce que ceci & cela » que:
Si ça allait même 10% plus vite, ça n’aurait pas forcément valu toujours le coup de faire de l’asynchrone. Là on est sur des ordres de grandeur qui ne laissent pas de doute possible.
Si ça allait 10% plus vite, ça aurait valu le coup quand même de faire de l’asynchrone. Mais la cause n’a rien à voir avec la vitesse. Dans un modèle synchrone, on a un thread qui est inutilisable pendant qu’on attend le réseau ou une lecture disque. En gros, on a payé au système le coût mémoire d’un thread (8Mo sur certains Linux (http://blog.emptycrate.com/node/275), mais ça varie de système en système), mais on laisse ce thread inutile pendant plusieurs millions de cycle machine. Quand une autre requête arrive, soit on la laisse attendre, soit on ouvre un autre thread (donc on « repaye » le coût mémoire, alors qu’on a déjà un thread « payé » qui ne branle rien).
Dans un modèle asynchrone comme node, l’attente est déléguée au système. Donc le même thread est utilisé pour traiter toutes les requêtes. En gros, on paye une fois pour un thread, mais on le rentabilise bien mieux.
Concernant la délégation de l’attente, soit le système attend un signal du matériel (ce qui ne coûte presque rien ou en tout cas rien à voir avec 8Mo), soit, quand il n’existe pas d’API asynchrone (certaines BDD, par exemple, mais ça reste assez rare), node délègue l’attente synchrone à des thread dédiés à ça à des fins d’émulation et ils travaillent à créer des API asynchrones là où c’est utiles pour avoir une vraie attente asynchrone.
Un article intéressant sur le sujet : http://blog.mixu.net/2011/02/01/understanding-the-node-js-event-loop/
L’asynchrone, ça va plus vite, mais c’est un effet de bord plus qu’une finalité. En l’occurrence, ça permet surtout de tenir la charge bien mieux que n’importe quel système où tout est synchrone.
Aussi, ne pas avoir plusieurs thread permet de ne pas avoir de coût de « context switching » (ou en tout cas, des magnitudes plus faibles)
Merci pour ces précisions très complètes et intéressantes 🙂
J’irai lire l’article pointé avec grande attention, j’avoue que c’est frustrant de toucher du doigt un sujet sans tout en saisir…
can you do a version where you try out coroutines ?
e.g
https://github.com/chriso/synchronous
I’m not fond of patching node to use Fiber, but why not, I may test this when I have time yes, that could be interesting!