Benchmark Node.JS: méthodes synchrones ou asynchrones ?

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 ;)

6 réflexions au sujet de « Benchmark Node.JS: méthodes synchrones ou asynchrones ? »

  1. David Bruant

    « 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.

    Répondre
    1. naholyr Auteur de l’article

      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:

      1. j’ai vraiment compris le pourquoi du comment, il serait donc hypocrite de ma part de faire croire que je pourrais ré-expliquer ça ici
      2. je sais à quel point ça va plus vite ;) et en ça, je crois qu’un benchmark est absolument nécessaire

      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.

      Répondre
      1. David Bruant

        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)

        Répondre
        1. naholyr Auteur de l’article

          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…

          Répondre
    1. naholyr Auteur de l’article

      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!

      Répondre

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>