Async JavaScript - 03 - Les Macrotasks

setTimeout et le mythe du 0ms, setInterval et le problème de drift, setImmediate, et la regle un macrotask par itération.

Les Macrotasks

Ce que tu vas apprendre

  • Ce que sont les macrotasks (ou "tasks") et quelles APIs en creent
  • Le mythe du setTimeout(fn, 0) -- pourquoi le delai minimum est 4ms dans les navigateurs
  • Le problème de drift avec setInterval
  • setImmediate dans Node.js et sa relation avec la phase check
  • La regle : un seul macrotask par itération de l'event loop

Prerequisites

  • Avoir lu l'article sur l'event loop
  • Connaitre setTimeout et setInterval

Quand j'ai découvert que setTimeout(fn, 0) n'exécuté pas fn immédiatement, j'ai cru a un bug. "Zero millisecondes, ca veut dire maintenant, non ?" Non. Et la raison touche au coeur de comment l'event loop gere les taches.

Qu'est-ce qu'un macrotask ?

Un macrotask (ou simplement "task" dans la spec HTML) est une unité de travail que l'event loop prend dans une file d'attente et exécuté. La call stack doit etre vide pour qu'un macrotask soit pris.

Les APIs qui creent des macrotasks :

  • setTimeout(fn, delay)
  • setInterval(fn, delay)
  • setImmediate(fn) (Node.js uniquement)
  • Les callbacks I/O (lecture fichier, requêtes réseau)
  • MessageChannel.onmessage
  • Les événements DOM (click, scroll, etc.)
  • Le script initial lui-meme (le premier <script> est un macrotask)
File des macrotasks (simplifiee)
+--------+--------+--------+--------+
| script | click  | timer  | I/O    |
| initial| handler| cb     | cb     |
+--------+--------+--------+--------+
    ^
    |
    L'event loop prend UN element a la fois

Le mythe du setTimeout 0

javascriptconsole.log("avant");
setTimeout(() => console.log("timeout"), 0);
console.log("apres");
// avant, apres, timeout

On s'attend parfois a ce que le callback s'exécuté entre "avant" et "apres". Mais non -- il s'exécuté apres que tout le code synchrone a termine.

Et le delai n'est meme pas 0ms. La spec HTML dit :

Si le delai est inférieur a 4ms et que l'imbrication dépassé 5 niveaux, le delai est force a 4ms minimum.

En pratique, meme au premier niveau, la plupart des navigateurs appliquent un minimum de ~1ms. Et dans les onglets en arriere-plan, Chrome augmente le minimum a 1000ms pour economiser la batterie.

javascript// Test du delai reel
const start = performance.now();
setTimeout(() => {
  console.log(`Delai reel : ${performance.now() - start}ms`);
}, 0);
// Typiquement : "Delai reel : 1.2ms" a "Delai reel : 4.5ms"

L'imbrication et les 4ms

La regle des 4ms s'applique spécifiquement aux timers imbriques :

javascriptfunction nested(depth) {
  const start = performance.now();
  setTimeout(() => {
    console.log(`Niveau ${depth}: ${performance.now() - start}ms`);
    if (depth < 10) nested(depth + 1);
  }, 0);
}
nested(1);

// Niveau 1: ~1ms
// Niveau 2: ~1ms
// Niveau 3: ~1ms
// Niveau 4: ~1ms
// Niveau 5: ~4ms   <-- le clamp s'active
// Niveau 6: ~4ms
// ...

C'est un choix delibere des navigateurs. Sans ce minimum, un setTimeout(fn, 0) en boucle consommerait 100% du CPU et rendrait la page inutilisable.

setInterval et le problème de drift

setInterval exécuté un callback a intervalle régulier. En theorie. En pratique, le drift s'accumule.

javascriptlet count = 0;
const start = Date.now();

const id = setInterval(() => {
  count++;
  const elapsed = Date.now() - start;
  const expected = count * 100;
  console.log(`Iteration ${count}: ${elapsed}ms (drift: ${elapsed - expected}ms)`);
  if (count >= 10) clearInterval(id);
}, 100);

// Iteration 1: 102ms (drift: 2ms)
// Iteration 2: 205ms (drift: 5ms)
// Iteration 3: 309ms (drift: 9ms)
// ...le drift s'accumule

Pourquoi ? Parce que setInterval planifie le prochain tick à partir du moment ou le callback est enregistre, pas à partir de la fin de son exécution. Si le callback prend 20ms et que l'interval est de 100ms, le prochain sera a 100ms du début -- mais il commencera 20ms "en retard" par rapport a ce qu'on attendrait.

Attendu :  |--100ms--|--100ms--|--100ms--|
           cb        cb        cb        cb

Reel :     |--100ms--|--100ms--|--100ms--|
           [cb 20ms]   [cb 20ms]   [cb 20ms]
              ^drift      ^plus de drift

La solution : setTimeout recursif

Pour un timing plus precis, utilise setTimeout en boucle avec correction :

javascriptfunction preciseInterval(fn, interval) {
  let expected = Date.now() + interval;

  function step() {
    fn();
    const drift = Date.now() - expected;
    expected += interval;
    setTimeout(step, Math.max(0, interval - drift));
  }

  setTimeout(step, interval);
}

// Le drift est corrige a chaque iteration
preciseInterval(() => {
  console.log(Date.now());
}, 100);

J'utilise cette technique sur paltemps.fr pour un compteur qui doit rester synchronise avec une API. Sans correction, apres 10 minutes le decalage est visible a l'oeil nu.

setImmediate (Node.js)

setImmediate est spécifique a Node.js. Il exécuté le callback dans la phase check de l'event loop, apres la phase poll.

javascript// Node.js uniquement
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

L'ordre entre ces deux depends du contexte. A la racine du script, c'est non-déterministe (ca depend de la latence du processus). Mais dans un callback I/O, setImmediate est toujours exécuté en premier :

javascriptconst fs = require("fs");

fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
// Toujours : immediate, timeout

Pourquoi ? Parce que le callback readFile s'exécuté dans la phase poll. La phase suivante est check (setImmediate). La phase timers ne viendra qu'au prochain cycle.

Phase poll (on est ici)
  -> readFile callback s'execute
  -> enregistre setTimeout et setImmediate

Phase check (juste apres)
  -> setImmediate s'execute    => "immediate"

... cycle suivant ...

Phase timers
  -> setTimeout s'execute      => "timeout"

Un macrotask par itération

Dans le navigateur, l'event loop prend un seul macrotask par itération, puis draine toutes les microtasks, puis fait le rendu si nécessaire. C'est ca qui permet au navigateur de rester reactif.

Si l'event loop executait tous les macrotasks d'un coup, un setTimeout(fn, 0) en boucle empecherait tout rendu. Avec la regle "un par un", le navigateur a l'occasion de dessiner entre chaque macrotask.

javascript// Ceci ne bloque PAS le rendu
function chunkedWork(items) {
  if (items.length === 0) return;
  processItem(items.shift());   // traite un element
  setTimeout(() => chunkedWork(items), 0);  // prochain macrotask
  // le navigateur peut faire un rendu entre chaque iteration
}

C'est la base du pattern "chunking" pour les traitements lourds. On verra des patterns plus avances plus tard dans la serie.

Résumé

  • Les macrotasks sont des unités de travail prises une par une dans la file
  • setTimeout(fn, 0) a un delai minimum de ~1-4ms, pas 0
  • setInterval accumule du drift -- préféré setTimeout recursif avec correction
  • setImmediate (Node.js) s'exécuté dans la phase check, apres poll
  • Un seul macrotask par itération de la boucle, ce qui laisse le rendu respirer

Precedent : L'Event Loop | Suivant : Les Microtasks

Sources

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