L'event loop — un thread qui tourne en boucle
Call stack, event queue, microtask queue — comment un seul thread donne l'illusion de faire tout en même temps.
Imagine un cuisinier seul en cuisine. Il ne peut faire qu'une chose à la fois, mais il lance le four, met une casserole à chauffer, puis coupe des légumes pendant que les deux cuissent. Il n'est pas en train de faire trois choses simultanément — il délègue l'attente et revient consulter quand c'est prêt.
L'event loop fonctionne exactement ainsi. Un seul thread exécute le code JavaScript — jamais deux instructions en même temps. Mais il peut lancer des opérations longues (réseau, timers, I/O) et continuer à traiter d'autres tâches pendant qu'elles s'exécutent ailleurs.
Call Stack : la pile d'appels de fonctions en cours. LIFO.
Heap : zone mémoire où vivent les objets alloués.
Task Queue (macrotask queue) : callbacks en attente — setTimeout, setInterval, événements I/O.
Microtask Queue : callbacks de haute priorité — Promise.then, queueMicrotask. Vidée entièrement avant la prochaine macrotask.
La boucle elle-même est simple :
// Pseudo-code de l'event loop while (true) { // 1. Exécute tout le code synchrone (vide la call stack) executeSynchronousCode(); // 2. Vide entièrement la microtask queue while (microtaskQueue.length > 0) { microtaskQueue.shift()(); } // 3. Prend UNE macrotask dans la task queue if (taskQueue.length > 0) { taskQueue.shift()(); } // 4. Recommence }
Promise.then s'exécute avant setTimeout(fn, 0) même si le setTimeout a été enregistré en premier.
Lance la simulation pour observer comment A, setTimeout, Promise.then et D traversent les queues.
La call stack — LIFO des appels de fonctions
Chaque appel de fonction empile un frame. LIFO. Une exception remonte la pile. Toujours synchrone.
La call stack est une pile : le dernier arrivé est le premier sorti (LIFO — Last In, First Out). À chaque appel de fonction, un frame est empilé. Quand la fonction retourne, son frame est dépilé. C'est le mécanisme de base de tout langage à thread unique.
Chaque frame contient : la référence à la fonction, ses arguments, ses variables locales, et le pointeur de retour (là où l'exécution reprend après le retour).
Voici ce qui se passe quand tu appelles trois fonctions imbriquées :
function c() { return 42; } function b() { return c(); } function a() { return b(); } a(); // appel initial
La stack évolue ainsi :
Stack overflow
La call stack a une taille limitée (environ 10 000 frames en V8). Une récursion infinie la remplit jusqu'à l'erreur Maximum call stack size exceeded.
function infini() { return infini(); // aucune condition d'arrêt } infini(); // RangeError: Maximum call stack size exceeded
Error.stack montre l'ordre LIFO des appels — le dernier appel (le plus imbriqué) est en haut.
La stack est toujours synchrone
Un point crucial : la call stack ne peut jamais être interrompue. Une fonction asynchrone ne "met pas en pause" la stack — elle retourne immédiatement et enregistre un callback quelque part. Le callback sera exécuté plus tard, quand la stack sera vide.
console.log('début'); setTimeout(function() { console.log('timer'); // exécuté APRÈS que la stack soit vide }, 0); console.log('fin'); // Sortie : début → fin → timer
Macrotasks et Microtasks — ordre d'exécution
Les microtasks (Promise.then) passent toujours avant la prochaine macrotask (setTimeout) — une règle à connaître par cœur.
L'event loop gère deux types de files d'attente avec des priorités différentes. Comprendre la différence entre macrotasks et microtasks est l'une des règles les plus importantes pour écrire du code async correct.
Callbacks enregistrés par :
setTimeout, setInterval, setImmediate (Node.js), événements DOM, requêtes XHR terminées, I/O complété.L'event loop en traite une seule par tour, puis vide les microtasks.
Callbacks enregistrés par :
Promise.then/catch/finally, queueMicrotask(), MutationObserver (navigateur).Toute la queue est vidée avant la prochaine macrotask, sans exception. Même si de nouvelles microtasks sont ajoutées pendant la vidange.
La règle mnémotechnique : micro avant macro. Après chaque bout de code synchrone, toutes les microtasks s'exécutent. Puis seulement la prochaine macrotask.
console.log('1 — synchrone'); setTimeout(function() { console.log('4 — macrotask'); }, 0); Promise.resolve() .then(function() { console.log('3 — microtask'); }); queueMicrotask(function() { console.log('encore 3 — microtask aussi'); }); console.log('2 — synchrone'); // Sortie : 1 → 2 → 3 → encore 3 → 4
// PIÈGE — microtask récursive qui bloque le rendu function loopMicrotask() { Promise.resolve().then(loopMicrotask); // ajoute toujours une nouvelle microtask } loopMicrotask(); // le rendu de la page est gelé
Avance pas à pas pour voir l'ordre d'exécution de setTimeout, Promise.then et queueMicrotask.
I/O non-bloquant — epoll et le noyau
setTimeout et fetch ne bloquent pas — ils délèguent au kernel qui rappelle quand c'est prêt. C'est ça, l'I/O non-bloquant.
Voici le cœur du mystère : si JavaScript est mono-thread, comment fait-il pour gérer des milliers de connexions simultanées sans se bloquer ? La réponse est dans le noyau du système d'exploitation.
Un entier que le kernel associe à une ressource I/O ouverte : socket réseau, fichier disque, pipe. Quand tu appelles
open(), socket() etc., le kernel retourne un fd. Toutes les opérations (read, write, close) passent par ce fd.
epoll / kqueue / IOCP
Le kernel expose des APIs permettant à un programme de surveiller des milliers de fds sans bloquer :
| Système | API | Mécanisme |
|---|---|---|
| Linux | epoll |
Enregistre les fds à surveiller, epoll_wait() retourne ceux qui sont prêts |
| macOS / BSD | kqueue |
Similaire, gère aussi les signaux et les timers |
| Windows | IOCP | I/O Completion Ports — modèle orienté completion plutôt que readiness |
Node.js utilise libuv, une bibliothèque C qui abstrait ces trois mécanismes derrière une interface unifiée.
Le cycle d'une opération réseau
// Ce qui se passe quand tu fais : fetch('https://api.example.com/data') .then(res => res.json()) .then(data => console.log(data)); // 1. JS crée une socket, l'enregistre dans epoll (non-bloquant) // 2. JS continue à s'exécuter — call stack libre // 3. Le kernel fait la connexion TCP + HTTP en arrière-plan // 4. Quand la réponse arrive : epoll_wait() retourne le fd prêt // 5. libuv lit la réponse, crée un callback et le pousse dans la Task Queue // 6. L'event loop traite le callback → .then() s'exécute
Thread pool — pour les opérations qui ne peuvent pas être async
Certaines opérations n'ont pas de syscall async natif. libuv maintient un thread pool (4 threads par défaut, configurable via UV_THREADPOOL_SIZE) pour les traiter sans bloquer l'event loop :
| Opération | Pourquoi le thread pool |
|---|---|
fs.readFile, fs.stat | Opérations fichier synchrones dans le kernel sur certains OS |
dns.lookup() | Résolution DNS système (getaddrinfo) est bloquante |
| crypto (bcrypt, scrypt) | CPU-intensif, ne doit pas bloquer l'event loop |
| zlib (compression) | CPU-intensif |
epoll directement — pas le thread pool. C'est pour ça que Node.js peut gérer des milliers de connexions avec seulement 4 threads de pool.
Promises et async/await
Promise.all, Promise.race, async/await — les primitives du parallélisme coopératif. await suspend la fonction, pas le thread.
Les Promises sont la réponse à l'enfer des callbacks imbriqués. Une Promise représente une valeur qui n'est pas encore disponible mais le sera (ou non) dans le futur.
pending : en attente — ni résolue ni rejetée.
fulfilled : résolue avec une valeur.
rejected : rejetée avec une raison (erreur).
Une fois sortie de pending, une Promise est immuable — impossible de changer son état.
Chaining de Promises
Chaque .then() retourne une nouvelle Promise. C'est ce qui permet le chaînage :
fetch('/api/user/1') .then(res => res.json()) .then(user => fetch(`/api/posts?userId=${user.id}`)) .then(res => res.json()) .then(posts => console.log(posts)) .catch(err => console.error(err));
async/await — sucre syntaxique sur les Promises
async/await ne crée pas de nouveau mécanisme — c'est une façon plus lisible d'écrire du code basé sur les Promises. Une fonction async retourne toujours une Promise. await suspend la fonction courante (pas le thread) jusqu'à ce que la Promise soit résolue.
// Équivalent exact du chaînage ci-dessus async function loadPosts(userId) { try { const user = await (await fetch(`/api/user/${userId}`)).json(); const posts = await (await fetch(`/api/posts?userId=${user.id}`)).json(); return posts; } catch (err) { console.error(err); } }
async est "en attente" sur un await, l'event loop continue à traiter d'autres callbacks. La fonction est simplement suspendue — son contexte est sauvegardé en mémoire.
Les combinateurs de Promises
| Méthode | Comportement | Cas d'usage |
|---|---|---|
Promise.all([]) |
Attend toutes — échoue dès le premier rejet | Requêtes parallèles toutes requises |
Promise.allSettled([]) |
Attend toutes — ne rejette jamais, retourne status + value/reason | Audit, rapports — on veut tous les résultats même partiels |
Promise.race([]) |
Retourne dès la première résolue ou rejetée | Timeout, fallback sur le plus rapide |
Promise.any([]) |
Retourne dès la première résolue (ignore les rejets) | Plusieurs endpoints alternatifs — premier disponible gagne |
// Parallélisme : les trois fetch lancés simultanément const [users, products, orders] = await Promise.all([ fetch('/api/users') .then(r => r.json()), fetch('/api/products') .then(r => r.json()), fetch('/api/orders') .then(r => r.json()), ]); // Timeout : abort si plus de 3 secondes const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000) ); const result = await Promise.race([fetch('/api/data'), timeout]);
Node.js — les 6 phases de l'event loop
timers → pending callbacks → poll → check → close — l'ordre précis qui explique pourquoi setImmediate est différent de setTimeout(0).
Node.js utilise une implémentation de l'event loop plus détaillée que le navigateur. libuv décompose le tour de boucle en 6 phases, chacune traitant un type de callbacks spécifique.
1. timers : callbacks
setTimeout et setInterval dont le délai est écoulé.2. pending callbacks : callbacks d'erreurs I/O reportés du tour précédent.
3. idle, prepare : usage interne libuv.
4. poll : récupère les nouveaux événements I/O ; s'il n'y a rien, attend.
5. check : callbacks
setImmediate().6. close callbacks : événements
close (ex. socket.destroy()).
Entre chaque transition de phase, Node.js vide la microtask queue (Promises) et traite process.nextTick().
process.nextTick() — priorité maximale
process.nextTick() n'est pas techniquement une phase de l'event loop. Son callback est exécuté immédiatement après le code synchrone courant, avant tout le reste — même avant les microtasks Promise.
console.log('A'); process.nextTick(() => console.log('B — nextTick')); Promise.resolve().then(() => console.log('C — Promise')); setTimeout(() => console.log('D — setTimeout'), 0); setImmediate(() => console.log('E — setImmediate')); console.log('F'); // Sortie : A → F → B → C → D → E // (D et E peuvent être inversés hors I/O — dépend du timing de l'OS)
setImmediate() vs setTimeout(fn, 0)
| setImmediate | setTimeout(fn, 0) | |
|---|---|---|
| Phase | check (phase 5) | timers (phase 1) |
| Délai minimum | Après la phase poll du tour courant | ≥ 1ms (norme HTML), variable |
| Dans un callback I/O | Toujours avant setTimeout | Toujours après setImmediate |
| En dehors d'un callback I/O | Ordre non déterminé | Ordre non déterminé |
setImmediate() quand tu veux que quelque chose s'exécute après l'I/O courant, sans délai supplémentaire. C'est plus prédictible que setTimeout(fn, 0) dans un contexte serveur.
process.nextTick récursif peut affamer l'event loop. Node.js ne passe jamais à la phase suivante si la queue nextTick n'est pas vide.
PHP — Fibers, Swoole et FrankenPHP
PHP traditionnel = synchrone. PHP 8.1 Fibers + ReactPHP/AMPHP = concurrence coopérative. FrankenPHP = workers Go.
PHP a longtemps été exclusivement synchrone. Un script s'exécute de haut en bas, une requête par processus (FPM). Depuis PHP 8.1, l'écosystème a profondément évolué.
Chaque requête HTTP obtient un processus PHP dédié. Si la requête fait une requête SQL de 200ms, le processus est bloqué 200ms. Pour 1000 requêtes simultanées, il faut 1000 processus.
PHP Fibers (8.1+) — coroutines natives
Les Fibers sont les coroutines de PHP. Un Fiber peut être suspendu (Fiber::suspend()) et repris (Fiber::resume()) sans bloquer le thread. C'est la primitive sur laquelle AMPHP v3 est construit.
$fiber = new Fiber(function (): void { $value = Fiber::suspend('premier'); // rend la main, retourne 'premier' echo "repris avec : $value\n"; // exécuté après resume() }); $yielded = $fiber->start(); // lance, reçoit 'premier' echo "suspendu avec : $yielded\n"; // suspendu avec : premier $fiber->resume('bonjour'); // repris avec : bonjour
Swoole — extension C avec event loop propre
Swoole est une extension PHP en C qui remplace FPM par un serveur d'application avec sa propre event loop, ses coroutines et son thread pool. Code PHP standard, mais chaque requête s'exécute dans une coroutine.
use Swoole\Coroutine; // Deux requêtes HTTP en parallèle dans une coroutine Swoole Coroutine\run(function () { [$r1, $r2] = Coroutine::map([ fn() => Coroutine\Http\Client::get('https://api.example.com/a'), fn() => Coroutine\Http\Client::get('https://api.example.com/b'), ]); // r1 et r2 sont arrivés en parallèle });
ReactPHP — event loop pure PHP
ReactPHP implémente une event loop en PHP pur (basée sur stream_select ou libevent). Tout est géré par callbacks/Promises, sans extension C.
$loop = React\EventLoop\Factory::create(); $browser = new React\Http\Browser($loop); $browser->get('https://httpbin.org/get') ->then(function ($response) { echo $response->getBody(); }); $loop->run(); // démarre la boucle
FrankenPHP — le serveur de ce projet
FrankenPHP est un serveur PHP écrit en Go qui intègre PHP directement dans le runtime Go. En mode worker, un processus PHP reste actif entre les requêtes (comme Swoole), éliminant le coût de démarrage. Les requêtes sont dispatchées par les goroutines Go.
Le worker PHP tourne en boucle : il traite une requête, puis appelle
frankenphp_handle_request() pour attendre la suivante. Le contexte PHP (classes, connexions DB) est réutilisé — démarrage en millisecondes au lieu de millisecondes à chaque requête.
Bloquer l'event loop — le piège classique
Un JSON.parse() de 500ms fige l'event loop. Toutes les requêtes attendent. Un seul appel lent peut mettre à genoux ton serveur Node.js.
C'est le piège le plus dangereux de Node.js et de tout système basé sur une event loop : une seule opération synchrone longue bloque TOUTES les requêtes simultanées.
Exemples d'opérations qui bloquent
// ❌ JSON.parse sur un fichier de 50 MB const data = JSON.parse(fs.readFileSync('big.json', 'utf8')); // ❌ Boucle CPU intensive function fibonacci(n) { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); } fibonacci(45); // ~5 secondes — event loop gelée // ❌ Regex catastrophique (ReDoS) const evil = /(a+)+b/; evil.test('aaaaaaaaaaaaaaaaaaaaac'); // temps exponentiel // ❌ Synchronous crypto const hash = crypto.scryptSync(password, salt, 64); // peut prendre 100-200ms
Mesurer le blocage
// Détecte quand l'event loop est bloquée plus de 50ms let lastTick = Date.now(); setInterval(function() { const delta = Date.now() - lastTick; if (delta > 50) console.warn(`Event loop lag: ${delta}ms`); lastTick = Date.now(); }, 10);
Solutions
| Problème | Solution |
|---|---|
| Calcul CPU long (crypto, image, compression) | Worker threads (worker_threads Node.js) |
| Rendu lourd dans le navigateur | Web Workers, requestIdleCallback |
| Gros JSON à parser | Streaming JSON parser, web worker |
| Algorithme récursif lent | Découpage en chunks via setTimeout(fn, 0) |
| ReDoS (regex catastrophique) | Valider les regexes, limiter la taille des inputs |
// Découpage d'une boucle longue en microtâches async function processLargeArray(items) { const CHUNK_SIZE = 1000; for (let i = 0; i < items.length; i += CHUNK_SIZE) { items.slice(i, i + CHUNK_SIZE).forEach(process); // cède la main à l'event loop entre chaque chunk await new Promise(resolve => setTimeout(resolve, 0)); } }
Concurrence vs parallélisme
L'event loop est concurrente (un thread, plusieurs tâches en vol). Le thread pool est parallèle (plusieurs threads). Deux mécanismes qui ne font pas la même chose.
Ces deux termes sont souvent confondus. Ils décrivent des mécanismes fondamentalement différents, et choisir le bon a des conséquences importantes sur l'architecture.
Plusieurs tâches progressent sur un seul thread en alternant leur exécution. À tout instant, une seule tâche s'exécute réellement. Le thread passe d'une tâche à l'autre quand l'une est en attente (I/O, timer).
→ C'est le modèle de l'event loop.
Plusieurs tâches s'exécutent simultanément sur plusieurs cœurs physiques. Deux threads s'exécutent au même instant sur deux cœurs différents.
→ C'est le modèle du thread pool.
Un chef orchestre plusieurs tâches. Quand une tâche attend (I/O), il passe à la suivante. Efficace pour le travail I/O-bound.
Thread: ─A─A─[wait]─B─B─A─A─B─
Toujours un seul thread. Coordination sans locks. Idéal : APIs, proxies, chat.
Plusieurs ouvriers travaillent simultanément. Chacun fait une tâche indépendante. Nécessaire pour le CPU-bound.
Thread 1: ─A─A─A─A─A─A─A─
Thread 2: ─B─B─B─B─B─B─B─
Plusieurs threads. Nécessite des locks/atomics. Idéal : encodage vidéo, crypto, ML.
Choisir le bon mécanisme
| Type de travail | Mécanisme | Pourquoi |
|---|---|---|
| Attente I/O (réseau, DB, fichier) | Event loop | Le thread n'est pas consommé pendant l'attente |
| Calcul CPU (encodage, chiffrement) | Thread pool / Worker | Libère l'event loop, utilise les cœurs disponibles |
| Traitement parallèle de données | Worker threads (Node.js) | Isolation mémoire, communication par messages |
| Rendu UI non-bloquant (navigateur) | Web Workers | Le main thread reste réactif aux événements UI |
Back-pressure
Si les callbacks arrivent plus vite qu'ils ne sont traités, la Task Queue grossit — et la mémoire avec elle. C'est la back-pressure : la source produit plus vite que le consommateur n'absorbe.
// Streams Node.js avec back-pressure explicite const readable = fs.createReadStream('big.log'); const writable = fs.createWriteStream('filtered.log'); // pipe() gère la back-pressure automatiquement // il pause le readable quand le writable est saturé readable.pipe(writable);
Synthèse — quand utiliser l'async
I/O-bound vs CPU-bound, workers vs event loop, async PHP quand ça vaut vraiment le coup.
Un tour d'horizon complet de l'event loop. Il est temps de fixer les règles de décision : quand utiliser l'async, quand éviter, et comment diagnostiquer les problèmes.
I/O-bound vs CPU-bound
Le goulot d'étranglement est l'attente d'une ressource externe : réseau, base de données, système de fichiers. Le thread est libre pendant l'attente. L'event loop est parfaite ici — elle réutilise ce temps pour d'autres tâches.
Le goulot est le calcul pur : chiffrement, compression, traitement d'image, algorithmes lourds. Le thread est occupé à 100%, il ne peut pas "attendre". L'event loop est impuissante — il faut du parallélisme (Worker threads).
| Situation | Recommandation | Outil |
|---|---|---|
| API qui fait des requêtes DB | Event loop (I/O-bound) | async/await Node.js, Promises |
| Proxy HTTP / WebSocket | Event loop — excellente scalabilité | Node.js natif |
| Encoding vidéo / image | Worker threads (CPU-bound) | worker_threads + MessageChannel |
| Traitement de logs volumineuses | Streams Node.js + back-pressure | fs.createReadStream + pipe() |
| PHP — API CRUD classique | Synchrone suffit (FPM / FrankenPHP) | Symfony + PostgreSQL |
| PHP — appels HTTP parallèles | Fibers + AMPHP ou Guzzle concurrent | AMPHP v3, ReactPHP |
| PHP — tâches CPU lourdes | Queue de travaux asynchrones | Symfony Messenger, RabbitMQ |
Récapitulatif des règles d'exécution
1. Code synchrone (call stack vidée entièrement)
2.
process.nextTick() (Node.js uniquement — avant les microtasks)3. Microtask Queue —
Promise.then, queueMicrotask() — vidée entièrement4. Une macrotask (Task Queue) —
setTimeout, setInterval, I/O5. Microtask Queue à nouveau — vidée entièrement
6. Prochaine macrotask → recommence
Diagnostiquer un problème d'event loop
- Latence qui monte progressivement : back-pressure — la queue grossit. Vérifier les débits source/consommateur.
- Toutes les requêtes lentes en même temps : blocage synchrone sur le thread — utiliser
clinic.jsou--profde Node. - Promise qui ne se résout jamais : probablement un resolve/reject non appelé, ou une Fiber/coroutine suspendue.
- setTimeout(fn, 0) exécuté après des dizaines de ms : event loop saturée — trop de microtasks ou macrotask longue avant.
Tu maîtrises maintenant les rouages de l'event loop : la call stack qui empile et dépile, les microtasks qui s'intercalent avant chaque macrotask, le kernel qui gère l'I/O en arrière-plan, et la différence fondamentale entre concurrence et parallélisme. Ces mécanismes sont au cœur de Node.js, des navigateurs modernes, et des implémentations async de PHP.