Le cas curieux des tests de performances setTimeout (0)

(Pour un effet optimal, lisez d'une voix rauque entouré d'un nuage de fumée)

Tout a commencé un jour d'automne gris. Le ciel était nuageux, le vent soufflait, et quelqu'un m'a dit que cela setTimeout(0)crée, en moyenne, un retard de 4 ms. Ils ont affirmé que c'était le temps qu'il fallait pour sortir le rappel de la pile, dans la file d'attente de rappel et de nouveau sur la pile. Je pensais que cela semblait louche (c'est le peu que vous m'imaginez en noir et blanc avec un cigare dans la bouche). Étant donné que le pipeline de rendu doit s'exécuter toutes les 16 ms pour permettre des animations fluides, 4 ms m'ont semblé long. Un très long temps.

Quelques tests naïfs dans les devtools l'ont console.time()confirmé. Le délai moyen sur 20 essais était d'environ 1,5 ms. Bien sûr, 20 essais ne font pas une taille d'échantillon suffisante, mais maintenant j'avais un point à prouver. Je voulais faire des tests à plus grande échelle qui pourraient m'apporter une réponse plus précise. Je pourrais alors, bien sûr, aller faire signe à mon collègue de montrer qu'il avait tort.

Sinon, pourquoi faisons-nous ce que nous faisons?

La méthode traditionnelle

Tout de suite, je me suis retrouvé dans l'eau chaude. Afin de mesurer le temps qu'il a fallu setTimeout(0)pour s'exécuter, j'avais besoin d'une fonction qui:

  • a pris un instantané de l'heure actuelle
  • réalisé setTimeout
  • puis quitté immédiatement pour que la pile soit claire et que le rappel planifié puisse s'exécuter et calculer le décalage horaire
  • et j'avais besoin que cette fonction s'exécute un nombre de fois suffisamment grand pour que les calculs soient statistiquement significatifs

Mais la construction de référence pour cela - la boucle for - ne fonctionnerait pas. Étant donné que la boucle for n'efface pas la pile tant qu'elle n'a pas exécuté chaque boucle, le rappel ne s'exécuterait pas immédiatement. Ou, pour le mettre dans le code, nous obtiendrions ceci:

Le problème ici était inhérent - si je voulais exécuter setTimeoutplusieurs fois automatiquement, je devrais le faire dans un autre contexte. Mais, tant que je courais depuis un autre contexte, il y aurait toujours un délai supplémentaire entre le moment où j'ai commencé le test et le moment où le rappel est exécuté.

Bien sûr, je pourrais le taudir comme certains de ces détectives bons à rien, écrire une fonction qui fait ce dont j'ai besoin, puis la copier et la coller 10 000 fois. J'apprendrais ce que je voulais savoir, mais l'exécution serait loin d'être gracieuse. Si j'allais frotter ça sur le visage de quelqu'un d'autre, je préférerais de beaucoup le faire d'une autre manière.

Puis ça m'est venu.

La méthode révolutionnaire

Je pourrais utiliser un web worker.

Les travailleurs Web s'exécutent sur un autre thread. Donc, si je place la setTimeoutlogique dans un web worker, je pourrais l'appeler plusieurs fois.Chaque appel créerait son propre contexte d'exécution, appelant setTimeoutet quittant immédiatement la fonction pour que le rappel puisse s'exécuter. J'avais hâte de travailler avec des web workers.

Il était temps de passer à mon Sublime Text de confiance.

J'ai commencé juste à tester les eaux. Avec ce code dans main.js:

Un peu de plomberie ici pour préparer le test, mais au départ, je voulais juste m'assurer de pouvoir communiquer correctement avec le web worker. C'était donc l'initiale worker.js:

Et même si cela a fonctionné comme un charme, cela a produit des résultats auxquels j'aurais dû m'attendre, mais ce n'était pas:

Étant tellement habitué à la synchronicité dans JS, je ne pouvais pas m'empêcher d'être surpris par cela. Au premier moment où je l'ai vu, mon cerveau a enregistré un bug. Mais, étant donné que chaque boucle configure un nouveau travailleur Web et qu'il s'exécute de manière asynchrone, il est logique que les nombres ne soient pas imprimés dans l'ordre.

Cela m'a peut-être surpris, mais cela fonctionnait comme prévu. Je pourrais continuer le test.

Ce que je voulais, c'est que la onmessagefonction du web worker s'enregistre t0, appelle setTimeout, puis quitte immédiatement pour ne pas bloquer la pile. Je pourrais, cependant, mettre des fonctionnalités supplémentaires dans le rappel, après avoir défini la valeur de t1. J'ai ajouté mon postMessagedans le rappel, donc il ne bloque pas la pile:

Et voici le main.jscode:

Cette version a un problème.

Bien sûr - depuis que je suis nouveau dans les web workers, je n'y avais pas pensé au début. Mais, lorsque plusieurs exécutions de la fonction ont continué à s'imprimer 0, j'ai pensé que quelque chose n'allait pas.

Quand j'ai imprimé les sommes de l'intérieur, onmessagej'ai eu ma réponse. La fonction principale se déplaçait de manière synchrone et n'attendait pas le retour du message du worker, elle calculait donc la moyenne avant que le web worker n'ait terminé.

Une solution rapide et sale consiste à ajouter un compteur et à ne faire le calcul que lorsque le compteur a atteint la valeur maximale. Alors voici le nouveaumain.js:

Et voici les résultats:

main(10): 0.1

main(100) : 1.41

main(1000) : 13.082

Oh. Ma. Eh bien, ce n'est pas génial, n'est-ce pas? Que se passe t-il ici?

J'ai sacrifié les tests de performance pour jeter un coup d'œil à l'intérieur. Je suis maintenant en train de connecter t0et t1 quand ils sont créés, juste pour voir ce qui se passe là-bas.

Et les résultats:

Il s'avère que mon attente d' t1être calculée immédiatement après t0était également erronée. Fondamentalement, le fait que rien chez les travailleurs Web ne soit synchrone signifie que mes hypothèses les plus élémentaires sur le comportement de mon code ne sont plus vraies. C'est un angle mort difficile à voir.

Non seulement cela, mais même les résultats que j'ai obtenus main(10)et main(100), qui m'ont rendu à l'origine très heureux et suffisant, n'étaient pas quelque chose sur lequel je pouvais compter.

L'asynchronicité des travailleurs Web en fait également un proxy peu fiable pour le comportement des choses dans notre pile habituelle. Ainsi, si la mesure des performances d' setTimeoutun web worker donne des résultats intéressants, ce ne sont pas des résultats qui répondent à notre question.

La méthode des manuels

J'étais frustré… ne pourrais-je vraiment pas trouver une solution JS vanille qui soit à la fois élégante et prouve que mon collègue a tort?

Et puis j'ai réalisé - il y avait quelque chose que je pouvais faire, mais je n'aimerais pas ça.

Je pourrais appeler setTimeoutrécursivement.

Maintenant, quand j'appelle, mainil appellera testRunnerquelles mesures t0puis planifiera le rappel. Le rappel s'exécute alors immédiatement, calcule t1et appelle à testRunnernouveau, jusqu'à ce qu'il atteigne le nombre d'appels souhaité.

Les résultats de ce code étaient particulièrement surprenants. Voici quelques impressions de main(10)et main(1000):

Les résultats sont sensiblement différents lors de l'appel de la fonction 1 000 fois par rapport à l'appel 10 fois. J'ai essayé cela à plusieurs reprises et j'ai obtenu en grande partie les mêmes résultats, avec une main(10)entrée à 3-4 ms et un main(1000)dépassement de 5 ms.

Pour être honnête, je ne suis pas sûr de ce qui se passe ici. J'ai cherché une réponse, mais je n'ai trouvé aucune explication raisonnable. Si vous lisez ceci et que vous avez une idée éclairée de ce qui se passe, j'aimerais avoir de vos nouvelles dans les commentaires.

La méthode éprouvée et vraie

Quelque part dans le fond de mon esprit, j'ai toujours su que cela arriverait… Les choses flashy sont bien pour ceux qui peuvent les obtenir, mais les essais et les vrais seront toujours là à la fin. Même si j'ai essayé de l'éviter, j'ai toujours su que c'était une option. setInterval.

Ce code fait l'affaire avec une force quelque peu brute. setIntervalexécute la fonction à plusieurs reprises, en attendant 50 ms entre chaque exécution, pour s'assurer que la pile est claire. C'est inélégant, mais teste exactement ce dont j'avais besoin.

Et les résultats étaient également prometteurs. Les temps semblent correspondre à mes attentes initiales - moins de 1,5 ms.

Enfin je pourrais mettre cette affaire au lit. J'avais eu des hauts et des bas, et ma part de résultats inattendus, mais à la fin, une seule chose comptait: j'avais prouvé qu'un autre développeur avait tort! C'etait assez bon pour moi.

Envie de jouer avec ce code? Découvrez-le ici: //github.com/NettaB/setTimeout-test