Microtâches et minuteries : l’ordre réel du code
Sonde d’ordre : le code synchrone finit d’abord, les promesses s’écoulent dans l’ordre de file, les microtâches imbriquées attendent, await reprend, une minuterie n’interrompt pas
BotBrowser Team
Vous voulez la documentation structurée pour Empreinte ?
Cet article fait partie de la bibliothèque éditoriale. Pour les étapes de configuration, la référence et les mises à jour continues, passez directement à la section docs.
BotBrowser prend en charge la relecture d’un fixture autorisé du même origine sous un profil déclaré et conserve des observations bornées sur l’ordre dans lequel une page a exécuté ses callbacks, mais il ne peut pas changer la façon dont un environnement planifie ses propres files, ne peut pas faire qu’une minuterie interrompe du code synchrone et ne fait pas d’un ordre de callbacks un signal d’identité. Cette revue lance une sonde d’ordre sur un ensemble fixe de formes et consigne quand le code synchrone se termine, quand les callbacks de promesse s’écoulent, quand une microtâche mise en file dans une microtâche attend la file devant elle, quand un await reprend, où tombe process.nextTick par rapport à une promesse, et si une boucle bloquante retarde une minuterie à délai nul.
Résumé
- Le code synchrone finit toujours d’abord : la sonde imprime
sync-start,sync-endavant tout callback de promesse, parce que la tâche courante s’exécute jusqu’au bout. - Les callbacks de promesse s’écoulent ensuite dans l’ordre où ils ont été mis en file, donc
promise-1,microtask-1,promise-2suivent l’ordre de planification et la minuterie vient en dernier. - Une microtâche mise en file dans une microtâche attend les microtâches déjà en file, donc l’ordre est
m1,m2,m1-nested,timeout. await nullreprend comme microtâche, donc le code après l’awaits’exécute avant une minuterie à délai nul.process.nextTickbat une promesse mise en file après lui quand les deux sont dans un callback de minuterie ou desetImmediate, ou dans le corps d’un script CommonJS ; un callback de promesse ou dequeueMicrotasket le corps d’un module ES imprimentpromise,nextTick.- Une minuterie à délai nul ne peut pas interrompre un bloc synchrone : la boucle de fifty milliseconds de la sonde fait tirer la minuterie à fifty milliseconds ou plus.
- Répéter la même forme donne le même ordre à chaque fois, donc la séquence est reproductible et non une course.
Sommaire
- Pourquoi le code synchrone finit avant tout callback
- Comment les callbacks de promesse s’écoulent dans l’ordre de planification
- Où une microtâche mise en file dans une microtâche attend
- Où await reprend
- Où nextTick tombe par rapport à une promesse
- Pourquoi une minuterie n’interrompt jamais une boucle bloquante
- Consigner une lecture d’ordre
Pourquoi le code synchrone finit avant tout callback
JavaScript exécute une tâche à la fois, et une tâche s’exécute jusqu’au bout avant que tout callback en file ne commence. La sonde pousse une étiquette au début et à la fin du bloc synchrone, met en file deux callbacks de promesse et une minuterie entre les deux, et lit le résultat :
node=v26.9.0
sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0
Les deux étiquettes synchrones viennent d’abord, dans l’ordre où elles se sont exécutées, et tout callback vient après. Rien de ce que fait le bloc synchrone ne peut laisser un callback en file s’exécuter au milieu, parce que l’environnement n’interrompt pas une tâche en cours pour en démarrer une en file. C’est la base de toute question d’ordre ci-dessous : la tâche courante possède le fil jusqu’à ce qu’elle retourne.
Cette règle explique pourquoi un long calcul synchrone gèle les minuteries et les callbacks de promesse d’une page. Une boucle qui prend deux cents millisecondes bloque chaque callback en file pendant ces deux cents millisecondes, et une page qui attend qu’un indicateur s’anime ou qu’une minuterie tire à l’heure ne voit rien jusqu’au retour de la boucle. Le gel n’est pas un défaut de planification ; c’est la règle d’achèvement appliquée comme spécifié, et le seul correctif est de découper le travail en tâches plus petites pour que l’environnement exécute les callbacks en file entre elles.
La règle d’achèvement décide aussi de ce qu’un registre peut affirmer sur un ordre de callbacks. Un callback qui s’est exécuté dans la même tâche qu’un autre n’est pas une observation séparée du premier, parce que les deux ont été mis en file et écoulés dans un seul tour. Un registre qui horodate chaque callback séparément peut montrer l’ordre dans un tour, mais pas à quel tour un callback appartenait sauf s’il consigne aussi la frontière de tâche, ce que la section suivante rend explicite.
Comment les callbacks de promesse s’écoulent dans l’ordre de planification
Les callbacks de promesse et ceux de queueMicrotask partagent une file de microtâches, et ils s’écoulent dans l’ordre où ils ont été mis en file, pas dans l’ordre où ils ont été créés. La sonde met en file une promesse, une microtâche et une autre promesse, puis une minuterie, et imprime l’ordre d’écoulement :
sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0
Les deux promesses et la microtâche s’exécutent exactement dans l’ordre où elles ont été mises en file : promise-1, puis microtask-1, puis promise-2. La minuterie s’exécute en dernier, une fois toute la file de microtâches vide. Qui attend que la microtâche double les promesses parce qu’elle ressemble à une primitive de plus bas niveau se trompe ; la file est premier entré, premier sorti, et le type de callback ne change pas sa position.
Cet ordre explique pourquoi une chaîne d’appels .then s’exécute l’une après l’autre dans un seul tour plutôt qu’une par minuterie. Une promesse qui se résout de façon synchrone met son callback en file immédiatement, et un second .then sur le résultat met un autre callback en file qui s’exécute après le premier, le tout avant la minuterie suivante. Une page qui attend une chaîne de promesses puis met l’écran à jour fait toute la chaîne dans un tour, ce qui explique que la mise à jour apparaisse d’un coup plutôt que par étapes.
L’ordre d’écoulement explique aussi pourquoi une chaîne illimitée de callbacks de promesse peut affamer les minuteries. Un callback qui met en file un autre callback de promesse, qui en met un autre, garde la file de microtâches non vide, et l’environnement ne démarre pas la minuterie suivante tant que la file n’est pas vide. Une boucle construite sur des callbacks de promesse qui ne s’arrête jamais bloque donc toute minuterie et tout événement d’entrée, même si aucun callback ne bloque le fil longtemps. L’affamement est le même gel que la boucle synchrone ci-dessus, arrivant par la file au lieu d’une boucle. Cet affamement est ce qu’une mesure de temps de la page voit comme gigue : performance timing fingerprinting montre comment une lecture d’horloge autour d’un écoulement de file peut transformer un retard de planification en un nombre qu’un collecteur enregistre.
Où une microtâche mise en file dans une microtâche attend
Une microtâche mise en file depuis l’intérieur d’une microtâche ne double pas la file ; elle s’ajoute à la fin et s’exécute après les callbacks déjà en attente. La sonde met en file deux microtâches dont la première en met une troisième, et imprime l’ordre :
nested-microtask -> m1,m2,m1-nested,timeout
L’ordre est m1, puis m2, puis m1-nested, puis la minuterie. La microtâche imbriquée ne s’exécute pas juste après m1 qui l’a mise en file ; elle attend que m2, déjà en file, ait fini. Alors seulement la file atteint m1-nested, et seulement quand toute la file est vide la minuterie s’exécute.
Ce comportement explique pourquoi une chaîne de promesses récursive ne cède rien jusqu’à la fin. Un callback qui attend sa propre continuation et met en file l’étape suivante continue d’ajouter à la file de microtâches, et la file s’écoule d’un pas par passe sans jamais atteindre la minuterie. Qui attend que la minuterie s’intercale entre les pas est surpris, parce que la microtâche imbriquée s’ajoute au lieu d’interrompre, et toute la chaîne s’exécute dans un tour.
La règle imbriquée explique aussi pourquoi un schéma « reporter au tour suivant » bâti sur des promesses ne reporte pas au-delà du tour courant. Un callback qui met en file une microtâche pour « laisser d’autre travail s’exécuter » ne laisse passer d’abord que les microtâches déjà en file ; il ne cède pas à une minuterie, à un événement d’entrée ni à un callback réseau, parce que tous ceux-là attendent que toute la file de microtâches se vide. Reporter au-delà du tour courant exige une minuterie ou une primitive de file de tâches, pas une microtâche, et un registre qui dit « reporté au tour suivant » sans nommer la file est ambigu.
Où await reprend
Un await suspend la fonction asynchrone et la reprend comme microtâche, donc le code après l’await s’exécute après le retour du bloc synchrone et avant toute minuterie. La sonde attend null, qui se résout immédiatement, et imprime l’ordre :
await-resumes-as-microtask -> async-start,sync,after-await,timeout
La fonction asynchrone démarre et pousse async-start, puis se suspend à l’await. Le code synchrone après l’appel pousse sync. La fonction reprend ensuite comme microtâche et pousse after-await. La minuterie s’exécute en dernier. Bien qu’await null n’ait rien à attendre, la reprise reste une microtâche et reste après la fin du bloc synchrone courant, ce qui explique que l’étiquette sync apparaisse entre les deux moitiés de la fonction asynchrone.
Cet écart explique pourquoi une fonction asynchrone qui paraît séquentielle n’est pas synchrone. Qui appelle une fonction asynchrone et attend que l’effet de sa première ligne soit visible immédiatement voit l’effet, mais qui attend que l’effet après le premier await soit visible dans la même instruction ne le voit pas, parce que la reprise est en file. L’écart est exactement une microtâche, invisible dans une lecture mono-thread mais visible dans toute sonde d’ordre qui étiquette les deux moitiés.
L’ordre de reprise décide aussi de l’entrelacement de deux fonctions asynchrones. Deux fonctions asynchrones qui attendent toutes deux une valeur résolue immédiatement reprennent dans l’ordre où leurs await ont été atteints, parce que chaque reprise est une microtâche en file. Une page qui lance deux chargements asynchrones et attend que le premier finisse son travail post-await en premier a raison tant que le premier a atteint son await en premier, et tort si c’est le second. L’entrelacement est déterministe et dépend de l’ordre où les await ont été atteints, pas de l’ordre où les fonctions ont été définies.
Où nextTick tombe par rapport à une promesse
process.nextTick est une file propre à Node qui s’exécute avant les microtâches de promesse dans la plupart des cas, et la sonde le montre gagnant dans un callback :
nextTick-before-promise -> nextTick,promise,timeout
nextTick-beats-later-promise-in-callback -> nextTick,promise
Dans les deux cas le callback de nextTick s’exécute avant le callback de promesse et avant la minuterie. Les deux paires ont été mises en file dans un callback de minuterie, donc une macrotâche, et à la fin d’une macrotâche Node vide toute la file de nextTick avant de démarrer la file de promesses ; nextTick gagne donc aussi dans le second cas même si la promesse a été mise en file en premier.
La règle est plus étroite que « nextTick gagne toujours ». Une sonde de suivi a mis la même paire en file dans quatre contextes et a imprimé nextTick,promise depuis le corps d’un script CommonJS et depuis un callback de minuterie, puis promise,nextTick depuis un callback de promesse et depuis un callback de queueMicrotask. Le corps d’un module ES se comporte comme les cas de microtâche et imprime aussi promise,nextTick. La coupure suit le contexte depuis lequel les lignes sont mises en file : une macrotâche vide sa file de nextTick avant la file de promesses, alors qu’un contexte de microtâche est déjà dans la file de promesses, donc une continuation mise en file là s’exécute avant que le contrôle revienne à nextTick.
C’est pourquoi la sonde de cet article isole chaque cas dans un callback de minuterie, et pourquoi les deux ordres qu’elle imprime sont des résultats de macrotâche. Une sonde qui met la paire en file au niveau supérieur de CommonJS la rejoint ; le niveau supérieur d’un module ES ou un callback de microtâche imprime l’inverse. Un registre qui dit « nextTick s’exécute d’abord » sans nommer le contexte a consigné un résultat qui ne se reproduira nulle part ailleurs.
Pourquoi une minuterie n’interrompt jamais une boucle bloquante
Une minuterie à délai nul n’interrompt pas un bloc synchrone en cours ; elle ne tire qu’après le retour du bloc. La sonde démarre une minuterie puis exécute une boucle de fifty milliseconds, et vérifie quand la minuterie a tiré :
timer-not-preemptive -> elapsed_at_least_50=true
La minuterie a tiré à fifty milliseconds ou plus, pas à zéro. L’environnement ne peut pas interrompre la tâche en cours, donc la minuterie attend que la boucle finisse et s’exécute au tour suivant. Le retard n’est pas un défaut de la minuterie ; c’est la règle d’achèvement de la première section appliquée à un bloc plus long, et la même règle qui fait qu’un long calcul gèle une page.
La même règle explique pourquoi un « sleep » bâti sur une boucle occupée est pire qu’une minuterie. Une boucle occupée qui attend deux cents millisecondes bloque chaque callback en file pendant deux cents millisecondes, tandis qu’une minuterie qui tire après deux cents millisecondes laisse chaque callback en file s’exécuter entre les deux. Un code qui remplace une minuterie par une boucle pour « simplifier » transforme une planification en gel, et un registre qui montre une minuterie tirant en retard est la preuve que la boucle s’est exécutée, pas que la minuterie était cassée.
Le cas bloquant fixe aussi l’attente pour une mesure. Une sonde qui mesure la latence d’une minuterie après un bloc connu peut utiliser la latence comme preuve que le bloc s’est exécuté, parce que la minuterie ne peut pas tirer plus tôt. Une sonde qui mesure la latence sans contrôler le bloc ne peut pas distinguer une minuterie en retard d’une machine lente, donc le registre doit nommer le bloc et sa durée à côté de la latence observée, sinon le nombre ne veut rien dire. L’ordre qu’un callback finit par recevoir est souvent celui qu’a laissé un tri : Tri de tableau et comparateurs montre pourquoi le tri par défaut ordonne par chaîne et pourquoi un comparateur peut déborder, donc un callback qui traite un lot trié hérite de cet ordre plutôt que de celui pour lequel il a été écrit.
La vérification de déterminisme boucle tout ce qui précède :
repeat-deterministic -> spt|spt|spt unique=1
Trois exécutions de la même forme donnent le même ordre, s, puis p, puis t, avec un seul résultat. L’ordre n’est pas une course qui paraît stable une fois ; c’est le comportement spécifié des files, et une exécution répétée le confirme. Une sonde qui trouverait des ordres différents entre répétitions mesurerait autre chose, comme une minuterie dont le délai était plus court que l’écoulement de la file, et le registre devrait expliquer la variation au lieu de l’appeler non-déterminisme.
Consigner une lecture d’ordre
Une lecture d’ordre soutient une affirmation étroite : cet environnement, cette forme, avec ces callbacks mis en file dans cet ordre, ont produit cette séquence. Six champs la gardent vérifiable, et aucun ne suppose un type de file ni une frontière de tour :
- L’environnement et sa boucle d’événements, parce qu’un navigateur n’a pas de
process.nextTicket ordonne les microtâches de promesse à la fin de la tâche courante. - La file exacte où chaque callback a été mis, promesse,
queueMicrotask,nextTickou minuterie, parce que la file décide l’ordre et le type de callback non. - L’ordre dans lequel les callbacks ont été mis en file, parce que la file de microtâches est premier entré, premier sorti et un registre sans l’ordre de mise en file ne peut pas reproduire l’écoulement.
- La frontière de tour, si la forme s’est exécutée dans une macrotâche comme un callback de minuterie ou de
setImmediate, dans le corps d’un script CommonJS, ou dans une microtâche comme un callback de promesse ou dequeueMicrotask, parce que les deux mêmes lignes imprimentnextTick,promisedans les deux premiers cas etpromise,nextTickdans les cas de microtâche. - La durée du bloc synchrone, parce qu’une boucle bloquante retarde chaque minuterie et un registre sans elle ne peut pas expliquer une minuterie en retard.
- Si la même forme a été répétée, parce qu’une seule exécution ne peut pas distinguer un ordre spécifié d’une course qui paraît stable.
Ce qu’une telle lecture ne peut pas montrer, c’est quoi que ce soit sur la façon dont l’ordre d’une page interagit avec ce qu’elle rapporte à un serveur. Une page peut exécuter ses callbacks dans un ordre déterministe et envoyer malgré tout une requête dont le moment dépend du réseau, et un serveur qui voit arriver une requête ne peut pas en déduire l’ordre de microtâches de la page. Pour les octets qu’un callback traite souvent, TextDecoder et TextEncoder montre pourquoi les mêmes octets décodent différemment selon l’étiquette, et pour le motif sur lequel un callback s’exécute souvent, RegExp lastIndex et flags montre pourquoi un motif global réutilisé change sa réponse entre les appels.
Deux habitudes gardent la lecture honnête avec le temps. D’abord, isolez chaque cas d’ordre dans sa propre tâche, parce qu’une sonde à portée partagée peut laisser du travail en file et changer l’ordre du cas suivant. Ensuite, consignez le type de file et la frontière de tour à côté de la séquence, parce que les deux mêmes lignes peuvent produire deux ordres différents selon l’endroit où elles se sont exécutées. Une file de promesses n’est pas non plus un combinateur, et les combinateurs de promesses montrent laquelle de plusieurs promesses déjà réglées le code appelant reçoit vraiment.
Sources publiques
- MDN : utiliser les microtâches avec queueMicrotask()
- MDN : queueMicrotask()
- MDN : await
- MDN : setTimeout()
- Node.js : la boucle d’événements, les minuteries et process.nextTick()
- BotBrowser : référence de cohérence d’automatisation
Conclusion
Traitez la file comme faisant partie du contrat d’ordre : le code synchrone finit d’abord, les callbacks de promesse et queueMicrotask s’écoulent premier entré, premier sorti, une microtâche mise en file dans une microtâche s’ajoute à la fin, et await reprend comme microtâche, donc tout cela s’exécute avant une minuterie à délai nul. process.nextTick bat une promesse mise en file après lui dans un callback de minuterie ou de setImmediate et dans le corps d’un script CommonJS, tandis qu’un callback de promesse ou de queueMicrotask et le corps d’un module ES impriment l’inverse ; isolez donc chaque cas dans sa propre tâche et nommez le contexte dans le registre. Une minuterie n’interrompt jamais une boucle bloquante, ce qui explique qu’une boucle de fifty milliseconds fasse tirer une minuterie à délai nul en retard. Consignez le type de file, l’ordre de mise en file, la frontière de tour et la durée du bloc, et répétez la forme une fois, pour qu’une séquence stockée soit reproductible plutôt qu’une course qui paraît stable.
Lecture suivante
Pour les octets qu’un callback manipule souvent, lisez TextDecoder et TextEncoder ; pour le motif sur lequel un callback s’exécute souvent, lisez RegExp lastIndex et flags.
Cet article fait partie du guide : Qu'est-ce qu'une empreinte de navigateur ?
Articles Connexes
Promise : ce qui échoue d’abord
Empreinteperformance.now : résolution et fingerprinting
EmpreinteOrdre des clés : les entiers trient, les chaînes restent
PlateformePriorités scheduler.postTask pour les tâches web
EmpreinteUnicode : quatre identités de texte lues par une page
EmpreinteTri de tableau et comparateurs : un piège subtil
Faites passer BotBrowser de la recherche à la production
Utilisez ces guides pour comprendre le modèle, puis passez à la validation multi-plateforme, aux contextes isolés et au déploiement navigateur prêt pour l'échelle.