Le thread unique de JavaScript contre le moteur multi-processus du navigateur
Découvrez pourquoi JavaScript s’exécute sur un seul thread, tandis que les navigateurs gèrent simultanément les tâches de réseau, de rendu et de temporisation grâce au mécanisme du cycle d’événements.
JavaScript s’exécute sur un seul fil d’exécution.
Vous avez probablement lu cette affirmation plus souvent que vous ne pouvez le compter.
Pourtant, lorsque vous chargez une page web moderne, vous pouvez voir tous les éléments suivants se produire en même temps :
Une requête réseau est en cours.
Une image est téléchargée et décodée.
Une animation CSS s’affiche sans heurt.
La page répond instantanément à un clic.
Le contenu est affiché à l’écran.
Et votre code JavaScript continue de s’exécuter tout au long du processus.
Alors, que se passe-t-il réellement en arrière-plan ?
Si JavaScript ne dispose que d’un seul fil d’exécution, qui gère tout le reste ?
La réponse à cette question révèle l’une des idées les plus importantes concernant le fonctionnement réel des navigateurs :
JavaScript et le navigateur ne sont pas la même chose.
JavaScript dispose d’un fil d’exécution principal
Lorsque les développeurs décrivent JavaScript comme étant à thread unique, ils font référence spécifiquement à la manière dont le code JavaScript s’exécute.
Votre code s’exécute sur un seul thread principal JavaScript.
Prenons cet extrait comme exemple :
console.log("One");
console.log("Two");
console.log("Three");
Chaque ligne s’exécute strictement après celle qui la précède.
JavaScript ne va pas exécuter ces trois instructions en même temps sur le même thread.
Il n’y a qu’une seule pile d’appels à gérer.
Un seul bloc de JavaScript s’exécute à un instant donné.
C’est l’essence du concept de thread unique dans ce contexte.
Mais c’est là que les choses deviennent plus subtiles.
Le navigateur lui-même est responsable de bien plus que simplement l’exécution de vos scripts.
Le navigateur a d’autres tâches que l’exécution du JavaScript
Un navigateur est une machine bien plus complexe que le moteur JavaScript seul.
Il doit gérer des tâches telles que :
- Récupération de données via le réseau
- Planification de compteurs temporels
- Capteur des entrées clavier et pointeur
- Génération d’images à l’écran
- Décodage des images
- Reproduction du son
- Gestion de la lecture vidéo
- Enregistrement des données sur disque
- Calcul du layout de la page
- Dessin des pixels lors de l’affichage
- Combinaison des calques lors de la composition
- Diverses autres tâches gérées par le navigateur et le système d’exploitation
Votre code JavaScript n’est pas celui qui exécute directement tout ceci.
Au lieu de cela, JavaScript peut déléguer les tâches au navigateur pour qu’il s’occupe des détails.
Par exemple :
fetch("/api/users");
Votre script déclenche la demande.
Mais votre JavaScript n’ouvre pas manuellement un socket ni ne transfère des octets individuels depuis le serveur lui-même.
Cette responsabilité incombe au navigateur et aux systèmes qui le sous-tendent.
Lorsque la réponse arrive, le navigateur planifie l’exécution de votre fonction de rappel ou de la continuation de la promesse sur le thread JavaScript.
Cela diffère considérablement de l’idée selon laquelle :
"JavaScript gère absolument tout."
Pensez à JavaScript comme un seul travailleur au sein d’un système bien plus vaste
Imaginons un restaurant comme modèle mental.
JavaScript correspond à un seul serveur qui prend les commandes.
Le navigateur représente l’ensemble des opérations du restaurant.
D’autres travailleurs sont chargés de différentes tâches en arrière-plan.
JavaScript pourrait dire :
"J’ai besoin de ces données depuis le serveur."
À ce moment-là, le navigateur prend en charge l’opération réseau.
JavaScript n’a pas besoin de s’arrêter et d’attendre que chaque octet soit reçu.
Il peut continuer à effectuer d’autres tâches.
Lorsque l’opération est terminée et prête à être traitée par JavaScript, la tâche correspondante est mise en file d’attente pour le thread JavaScript.
C’est là le principe de base sur lequel fonctionnent les API asynchrones des navigateurs.
Alors, que se passe-t-il avec fetch() ?
Prenons cet exemple :
console.log("Start");
fetch("/api/users")
.then(() => {
console.log("Users received");
});
console.log("End");
Les débutants imaginent souvent quelque chose comme ceci :
Start
↓
fetch()
↓
wait for server
↓
Users received
↓
End
Mais ce n’est pas une représentation exacte de ce qui se produit.
L’exécution de JavaScript ne s’arrête pas à l’appel de fetch() pour attendre la réponse.
Une représentation plus exacte ressemble à ceci :
JavaScript
│
├── Start fetch
│
▼
Browser handles network work
│
│
└───────────────┐
│
JavaScript │
continues │
│
▼ │
console.log("End") │
│
▼
Response becomes available
│
▼
Promise continuation
gets scheduled
│
▼
JavaScript runs it
Compte tenu de ce flux, la sortie de la console ressemble généralement à ceci :
Start
End
Users received
Le détail clé ici n’est pas seulement le fait que fetch() fonctionne de manière asynchrone.
Ce qui importe le plus, c’est qui est réellement en train d’attendre.
Le thread de JavaScript n’est jamais bloqué pendant qu’il attend une réponse du réseau.
Le cycle d’événements relie les éléments
C’est précisément là que le cycle d’événements entre en jeu.
Une version simplifiée de ce système ressemble à ceci :
Browser
│
┌──────────┼───────────┐
│ │ │
Network Timers User Input
│ │ │
└──────────┼───────────┘
│
▼
Scheduling queues
│
▼
Event Loop
│
▼
Call Stack
│
▼
JavaScript
N’oubliez pas que ce diagramme est une simplification.
Les composants internes des navigateurs sont en réalité bien plus complexes, et différents moteurs mettent en œuvre ces mécanismes à leur manière.
Néanmoins, il illustre l’idée essentielle :
L’exécution de JavaScript n’est qu’une partie d’un système bien plus vaste.
Les compteurs temporels ne font pas exécuter secrètement votre code en arrière-plan
Prenons cet exemple :
setTimeout(() => {
console.log("Done");
}, 1000);
Une façon tentante de l’imaginer est :
« JavaScript lance un thread distinct qui compte à rebours une seconde. »
Cette représentation mentale vous mène sur de mauvaises pistes.
C’est le navigateur, et non JavaScript lui-même, qui implémente le comportement des compteurs temporels. Une fois le délai spécifié écoulé, la fonction de rappel devient éligible à être planifiée. Ce n’est qu’à ce moment-là que JavaScript l’exécute réellement — et uniquement lorsque le thread principal est disponible pour la traiter.
Cela signifie que du code comme celui-ci est une mauvaise idée :
setTimeout(() => {
console.log("Done");
}, 1000);
while (true) {}
Pourquoi cela provoque-t-il des problèmes ?
Lorsque le délai prend fin, la fonction de rappel est marquée comme prête à s’exécuter, mais le thread principal est bloqué dans une boucle infinie et ne devient jamais disponible. La fonction de rappel n’a aucun moyen de s’imposer et d’interruption du JavaScript qui est actuellement en cours d’exécution.
Le navigateur peut certes savoir ceci :
« Le chronomètre a sonné. »
Mais le simple fait de le savoir ne suffit pas — JavaScript a encore besoin d’une opportunité pour s’exécuter réellement :
Main JavaScript thread
while (true) {
// never finishes
}
↓
Timer becomes ready
↓
Callback waits
↓
JavaScript never becomes available
C’est l’une des distinctions fondamentales à bien comprendre.
Être asynchrone ne signifie pas que votre fonction de rappel s’exécute sur un autre thread.
Alors pourquoi la page n’est-elle pas constamment figée ?
Parce que l’exécution du JavaScript n’est qu’une des nombreuses tâches gérées par le navigateur à un moment donné.
Cependant, il y a un point important à souligner.
Beaucoup de tâches qui déterminent la réactivité de votre page — y compris l’exécution du JavaScript et certaines étapes du processus de rendu — s’effectuent sur ce même thread principal.
C’est pourquoi un code comme celui-ci verrouillera toujours la page :
const start = performance.now();
while (performance.now() - start < 5000) {
// expensive work
}
Pendant cinq secondes entières, le thread principal est occupé à exécuter cette boucle. Pendant ce temps, toute manipulation d’interaction ou étape de rendu nécessitant également le thread principal doit attendre son tour.
C’est précisément la situation à laquelle les gens font référence lorsqu’ils disent :
« JavaScript est à thread unique. »
Concurrence au niveau du navigateur, single-threading au niveau de JavaScript
C’est la distinction essentielle à garder en tête.
Un navigateur, en tant que processus global, peut répartir différents types de tâches sur plusieurs threads. Les détails varient d’un navigateur et système d’exploitation à l’autre, mais en réalité, les navigateurs modernes sont conçus pour gérer de nombreuses tâches en même temps.
En termes généraux, les tâches peuvent être distribuées de la manière suivante :
Browser
│
├── JavaScript execution
├── Network activity
├── Rendering-related work
├── Image/media processing
├── Browser services
└── Other internal tasks
Cependant, rien de tout cela ne vous permet d’écrire quelque chose comme :
runThisFunctionOnAnotherBrowserThread();
et de faire en sorte que du code JavaScript arbitraire soit exécuté sur un autre thread.
Votre JavaScript habituel continue de s’exécuter selon le modèle d’exécution à un seul thread qu’il a toujours utilisé.
Si vous avez besoin spécifiquement de déplacer des calculs lourds en JavaScript hors du thread principal, c’est à cela que servent les Web Workers.
Conclusion
Le JavaScript à un seul thread ne signifie pas que le navigateur dans son ensemble soit limité à un seul thread.
Votre code s’exécute sur un seul thread principal, tandis que le navigateur lui-même s’occupe d’une grande variété d’autres tâches grâce à son propre mécanisme interne.
Des éléments tels que les appels réseau, les temporisateurs, le traitement des entrées utilisateur, l’affichage et la décodage multimédia peuvent tous se dérouler indépendamment de l’exécution de votre JavaScript.
Lorsque l’une de ces tâches en arrière-plan est prête à reprendre, le navigateur organise la fonction de rappel ou la poursuite de la promesse correspondante afin que votre JavaScript puisse la reprendre.
Néanmoins, le thread principal reste très chargé.
Un JavaScript qui s’exécute longtemps sur ce thread retardera toutes les autres tâches qui y ont accès en même temps — interactions, affichage et autres tâches en attente.
Ainsi, on obtient un navigateur globalement assez concurrentiel, associé à une exécution du JavaScript qui reste strictement single-threadée.
Comprendre cette séparation explique à la fois ce dont le JavaScript basé sur navigateur est capable et où se situent ses limites.
Points clés
Retenez ces trois points :
- Le JavaScript lui-même est à thread unique. Votre code s’exécute séquentiellement, étape par étape, sur le thread principal.
- Le navigateur est un environnement plus vaste et concurrentiel. En plus d’exécuter votre JavaScript, il gère en parallèle les réseaux, les temporisateurs, l’affichage, les entrées et le traitement des médias.
- Asynchrone ne signifie pas une exécution multithreadée de votre fonction de rappel. Le navigateur s’occupe réellement de l’attente, puis restitue le contrôle au JavaScript une fois que le thread principal en a la possibilité.
Une façon utile de le formuler :
Browser
│
├── JavaScript execution
├── Network activity
├── Timers
├── User input
├── Rendering
├── Media processing
└── Other browser systems
JavaScript n’est qu’un composant qui fonctionne à l’intérieur de ce système plus vaste, et non le système lui-même.
Avec cette approche en place, des concepts tels que le boucle d’événements, les API asynchrones, la figuration de l’interface utilisateur et la concurrence au niveau du navigateur deviennent bien plus faciles à comprendre.
Lectures complémentaires
- Comment process.nextTick() affame secrètement la boucle d’événements de Node.js — Explique pourquoi les appels récursifs à process.nextTick() bloquent complètement la phase de sondage de libuv et comment corriger ce problème en utilisant setImmediate().
- Comprendre les closures en JavaScript et la boucle d’événements — Découvrez comment les closures conservent les variables externes et comment la boucle d’événements organise la pile d’appels, les microtâches et les macrotâches à l’aide d’exemples de code concrets.