Async JavaScript - 05 - Ordre d'exécution

5 exercices 'que va afficher ce code ?' avec des explications detaillees pour maîtriser l'ordre d'exécution async.

Ordre d'exécution

Ce que tu vas apprendre

  • Comment predire l'ordre d'exécution de code mixant sync, macrotasks et microtasks
  • 5 exercices progressifs avec explications pas a pas
  • Les regles de priorité resumees en un schema
  • La question d'entretien que tout le monde rate

Prerequisites


"Que va afficher ce code ?" -- c'est la question d'entretien JavaScript la plus frequente et la plus redoutee. J'ai pose cette question a des dizaines de candidats. Le taux de reussite ? Peut-etre 20% pour les cas simples. Pour les cas complexes, quasiment personne ne répond correctement du premier coup.

La bonne nouvelle : ce n'est pas de l'intuition. C'est de la mecanique. Et une fois que tu connais les regles, tu peux résoudre n'importe quel cas.

Les regles

Voici l'ordre de priorité, du plus prioritaire au moins prioritaire :

+------------------------------------------+
|  1. Code synchrone (call stack)          |
|  2. process.nextTick (Node.js)           |
|  3. Microtasks (Promise.then,            |
|     queueMicrotask)                      |
|  4. Macrotasks (setTimeout, setInterval, |
|     setImmediate, I/O)                   |
+------------------------------------------+

Regle : TOUT le niveau N est draine avant
        de passer au niveau N+1

On applique ca exercice par exercice.

Exercice 1 : le classique

javascriptconsole.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

Reflechis avant de lire la suite.

Réponse : 1, 4, 3, 2

Explication :

  1. console.log("1") -- synchrone, exécuté immédiatement
  2. setTimeout(cb, 0) -- enregistre cb dans la file macrotasks
  3. Promise.resolve().then(cb) -- enregistre cb dans la file microtasks
  4. console.log("4") -- synchrone, exécuté immédiatement
  5. Call stack vide -> drain des microtasks -> console.log("3")
  6. Prochain macrotask -> console.log("2")

Si tu as eu bon, bravo. Ca se complique.

Exercice 2 : microtasks imbriquees

javascriptconsole.log("A");

setTimeout(() => {
  console.log("B");
  Promise.resolve().then(() => console.log("C"));
}, 0);

Promise.resolve().then(() => {
  console.log("D");
  setTimeout(() => console.log("E"), 0);
});

console.log("F");

Réponse : A, F, D, B, C, E

Explication pas a pas :

--- Execution synchrone (macrotask: script) ---
sync: console.log("A")          => A
sync: setTimeout(cbB, 0)        => macrotasks: [cbB]
sync: Promise.then(cbD)         => microtasks: [cbD]
sync: console.log("F")          => F

--- Drain microtasks ---
micro: cbD -> console.log("D")  => D
micro: cbD -> setTimeout(cbE,0) => macrotasks: [cbB, cbE]
microtasks: [vide]

--- Macrotask suivant: cbB ---
macro: cbB -> console.log("B")  => B
macro: cbB -> Promise.then(cbC) => microtasks: [cbC]

--- Drain microtasks ---
micro: cbC -> console.log("C")  => C

--- Macrotask suivant: cbE ---
macro: cbE -> console.log("E")  => E

Le piège ici : C s'affiche avant E parce que les microtasks créées dans un macrotask sont drainee avant le macrotask suivant.

Exercice 3 : process.nextTick (Node.js)

javascriptsetTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));

console.log("sync");

Réponse : sync, nextTick, promise, timeout, immediate (ou timeout/immediate inverses a la racine)

--- Synchrone ---
sync: enregistre timeout, immediate, promise, nextTick
sync: console.log("sync")       => sync

--- Drain (nextTick d'abord, puis microtasks) ---
nextTick: console.log("nextTick") => nextTick
micro: console.log("promise")     => promise

--- Macrotasks ---
timer: console.log("timeout")     => timeout
check: console.log("immediate")   => immediate

A la racine du script, l'ordre timeout/immediate est non-déterministe dans Node.js (ca depend du temps de démarrage). Dans un callback I/O, immediate passe toujours avant timeout, comme on l'a vu dans l'article sur les macrotasks.

Exercice 4 : Promise dans Promise

javascriptnew Promise((resolve) => {
  console.log("1");
  resolve();
  console.log("2");
}).then(() => {
  console.log("3");
  return new Promise((resolve) => {
    console.log("4");
    resolve();
  }).then(() => {
    console.log("5");
  });
}).then(() => {
  console.log("6");
});

console.log("7");

Réponse : 1, 2, 7, 3, 4, 5, 6

C'est celui que tout le monde rate en entretien. Decomposons :

--- Synchrone ---
Le constructeur new Promise execute son callback IMMEDIATEMENT
(c'est synchrone !)
-> console.log("1")             => 1
-> resolve() est appele
-> console.log("2")             => 2
(oui, le code apres resolve() s'execute quand meme)

Le .then(cb3) enregistre cb3 en microtask
Le .then(cb6) est chaine mais attend que cb3 termine
-> console.log("7")             => 7

--- Drain microtasks ---
cb3: console.log("3")           => 3
cb3: new Promise(...) s'execute de maniere synchrone
     -> console.log("4")        => 4
     -> resolve()
     Le .then(cb5) est enregistre en microtask

(la file microtask n'est pas vide, on continue)
cb5: console.log("5")           => 5
cb5 termine, la Promise retournee par cb3 est resolue
cb6 est enregistre en microtask

(on continue a drainer)
cb6: console.log("6")           => 6

Les pièges :

  • Le callback du constructeur new Promise est synchrone
  • resolve() ne "return" pas -- le code apres s'exécuté
  • Le .then final attend que la Promise retournee par le .then précédent soit résolue

Exercice 5 : le boss final

javascriptasync function foo() {
  console.log("foo start");
  await bar();
  console.log("foo end");
}

async function bar() {
  console.log("bar");
}

console.log("script start");

setTimeout(() => console.log("timeout"), 0);

foo();

new Promise((resolve) => {
  console.log("promise constructor");
  resolve();
}).then(() => {
  console.log("promise then");
});

console.log("script end");

Réponse : script start, foo start, bar, promise constructor, script end, foo end, promise then, timeout

Je recommande d'utiliser paltemps.fr comme pense-bete pour revisiter ces exercices. Voici le déroulement :

--- Synchrone ---
console.log("script start")         => script start
setTimeout(cb, 0)                   => macrotasks: [cb]
foo() est appele :
  console.log("foo start")          => foo start
  await bar() :
    bar() est appele :
      console.log("bar")            => bar
    bar() retourne (Promise resolved)
    await suspend foo, le reste de foo est une microtask
                                     => microtasks: [fooEnd]
(on revient au code appelant)
new Promise(executor) :
  console.log("promise constructor") => promise constructor
  resolve()
.then(cbThen)                        => microtasks: [fooEnd, cbThen]
console.log("script end")           => script end

--- Drain microtasks ---
fooEnd: console.log("foo end")      => foo end
cbThen: console.log("promise then") => promise then

--- Macrotask ---
cb: console.log("timeout")          => timeout

Le piège principal : await transforme le reste de la fonction async en microtask. C'est du sucre syntaxique pour .then(). Donc foo end et promise then sont dans la meme file microtasks, et foo end a ete enregistre en premier.

Aide-mémoire visuel

Code synchrone             ||||||||||||||||| (execute en premier)
                                   |
                                   v
process.nextTick (Node)    ||||||||||||||  (draine)
                                   |
                                   v
Microtasks                 |||||||||||||   (draine completement)
(Promise.then,                     |
 queueMicrotask,                   v
 await suite)              Rendu navigateur (si besoin)
                                   |
                                   v
UN macrotask               |||||||  (un seul)
                                   |
                                   +---> retour au drainage microtasks

Résumé

  • Le code synchrone s'exécuté toujours en premier, dans l'ordre d'apparition
  • process.nextTick a la priorité la plus haute parmi les callbacks (Node.js)
  • Les microtasks sont drainee complètement, y compris celles créées pendant le drainage
  • Un seul macrotask par itération, puis retour aux microtasks
  • await transforme la suite de la fonction en microtask
  • Le constructeur new Promise(fn) exécuté fn de manière synchrone

Precedent : Les Microtasks | Suivant : Les Promises en profondeur

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.