Apprendre JS : Comprendre l'Exécution du JavaScript

Moteur JavaScript, Heap, Call Stack, APIs Web, Callback Queue, Event Loop… découvrez comment JavaScript gère l'exécution de tâches asynchrones.

Icône de calendrier MAJ en
Intermédiaire
8 chapitres

JavaScript : synchrone par défaut

Par défaut, JavaScript est un langage monothread synchrone bloquant ; il ne fait qu’une chose à la fois. Ce comportement est dû au fonctionnement du moteur JavaScript utilisé pour son interprétation.

Moteur JavaScript

Le moteur JavaScript est en charge de l’exécution d’un programme JS.

Le moteur JavaScript le plus répandu est le moteur V8 de Google. Il est utilisé au sein de nombreux navigateurs modernes comme Chrome, Opera et Microsoft Edge, ainsi que dans le moteur d’exécution de Node.js. SpiderMonkey est quant à lui le moteur utilisé par Firefox.

Un moteur JavaScript est constitué de 2 composantes : le Heap et la Call Stack.

Heap

Le Heap (ou « tas » en français) est l’espace de mémoire stockant les objets créés lors de l’exécution d’un programme JavaScript.

Ces objets peuvent être :

  • Des chaînes de caractères
  • Des nombres
  • Des booléens
  • Des tableaux
  • Des fonctions
  • Des objets JS
  • Etc.

Le Heap est géré automatiquement par le Garbage Collector (ramasse-miettes en français) du moteur JavaScript, qui supprime les objets qui ne sont plus utilisés afin de libérer de l’espace mémoire.

Call Stack

Comme vous le savez désormais, JavaScript est single-thread. Cela signifie qu’on ne peut par défaut faire qu’une chose à la fois. La Call Stack (ou “pile d’exécution” en français) est une structure de données qui enregistre où en est l’exécution du programme.

  • Lorsque nous appelons une fonction, cette dernière est ajoutée en haut de la pile.
  • Lorsqu’une fonction est terminée, elle est enlevée du haut de la pile (on dit qu’elle est dépilée).

Schéma - Moteur JavaScript

Illustrons le mécanisme d’empilement de la Call Stack en considérant le programme suivant :

copié !
function one() {
	// ...
	two()
}

function two() {
	// ...
	setTimeout(() => {
		// ...
	}, 1000)
}

function three() {
	// ...
}

one()
three()

Voici comment la Call Stack va traiter les appels :

  • main()
  • main() / one()
  • main() / one() / two()
  • main() / one() / two() / setTimeout()
  • main() / one() / two()
  • main() / one()
  • main()
  • main() / three()
  • main()

Exemple : exécution synchrone

Considérons cet exemple de code synchrone :

copié !
for (let i=0; i<=1_000_000_000; i++) {}
console.log('Démo synchrone')

Au sein de chaque fonction, le programme est exécuté ligne par ligne. Il attend que la ligne ait été exécutée avant de passer à la suivante ; on dit qu’il s’exécute de manière séquentielle. Si vous lancez le script précédent dans votre navigateur, vous ne verrez apparaître Démo synchrone que lorsque la boucle sera terminée.

N’avoir qu’une seule pile d’exécution peut s’avérer très utile, car cela enlève tous les problèmes potentiels engendrés par le multithreading. En revanche, le monothreading présente un inconvénient de taille : tout est bloqué quand une tâche est longue… de l’UI (conduisant à une incapacité d’interagir avec la page) au navigateur tout entier.

Les fonctions synchrones sont appropriées pour effectuer des opérations courtes et rapides pour lesquelles il n’est pas problématique de monopoliser temporairement le fil d’exécution du programme.

En revanche, quand on développe un programme effectuant des opérations d’entrées/sorties (sur le système de fichier, sur un réseau, etc.), il vaut mieux lancer ces opérations en tâche de fond, tout en continuant l’exécution du reste du programme ; on parle d’asynchronisme.

Vers l’asynchronisme

Contrairement à d’autres langages de programmation qui utilisent plusieurs threads pour exécuter des opérations en parallèle, JavaScript utilise un modèle de concurrence basé sur les événements afin d’exécuter des tâches de manière asynchrone (exécution parallèle et différée), et maintenir ainsi une interface utilisateur réactive.

Les événements sont déclenchés par :

  • Des actions de l’utilisateur (évènements du DOM tels que des clics, saisies clavier…)
  • Des changements dans l’environnement d’exécution de JavaScript (timers, opérations d’entrées/sorties telles que des requêtes réseau avec fetch() ou le traitement de fichiers…).

Bien que le moteur JavaScript n’exécute le code que sur un unique thread via la Call Stack, cet asynchronisme est rendu possible en permettant au moteur JavaScript de communiquer avec des APIs Web qui vont effectuer les traitement asynchrones à part.

APIs Web

Évènements du DOM, timers (setTimeout() et setInterval()), requêtes réseau (AJAX, Fetch), etc., sont tous fournis côté client par les navigateurs et sont appelés API Web.

Les APIs Web permettent de traiter des évènements/tâches asynchrones en arrière-plan sur d’autres threads, tout en permettant au moteur JS d’exécuter le code principal sur un seul thread ; on parle de modèle de thread pool.

Lorsqu’une tâche asynchrone est déclenchée, elle peut être exécutée par n’importe quel thread disponible dans le pool. Cela permet d’améliorer la vitesse d’exécution des tâches et d’optimiser l’utilisation des ressources du système.

Schéma - Moteur JavaScript et APIs Web.

Lorsque ces APIs Web ont terminé leur traitement, elles ajoutent la fonction de callback à exécuter dans une file d’attente nommée Callback Queue.

Callback Queue

La Callback Queue va contenir les fonctions de callback à exécuter dès qu’une API Web a terminé l’exécution d’une tâche asynchrone.

Schéma - Moteur JavaScript, APIs Web et Callback Queue.

Il est intéressant de souligner qu’il existe 2 types de tâches asynchrones :

  • Les tâches (ou macro-tâches) : de longue durée, telles que les évènements (click, keyup…), les timers, le traitement de fichiers, les requêtes réseau, etc.
  • Les micro-tâches : courtes, telles que la résolution de promesses et les mutations du DOM (innerHTML, setAttribute()…).

Les micro-tâches sont prioritaires sur les tâches classiques et seront exécutés immédiatement après l’exécution de la tâche en cours. Nous ne rentrons pas dans les détails ici, mais elles sont en réalité déplacées dans une file d’attente spécifique nommée Job Queue ou Microtask Queue.

Dès lors qu’une tâche asynchrone standard est terminée, son callback est placé par les APIs Web dans une file d’attente appelée Callback Queue. Dès lors qu’une micro-tâche asynchrone est terminée, son callback est placé dans une file d’attente appelée Microtask Queue.

Pour être intégrés à la Call Stack, les résultats de ces APIs Web sont traités par la boucle d’évènement (ou « Event Loop ») qui est à la base du fonctionnement asynchrone de JavaScript.

Event Loop

L’Event Loop consiste à boucler sur l’ensemble des tâches présentes dans la Callback Queue afin de les déplacer successivement dans la Call Stack. L’Event Loop est garante du bon ordre d’exécution de ces tâches.

Il s’agit en quelques sortes d’une boucle while qui tourne tant que la Call Stack est vide, cherchant à la remplir avec d’éventuels callbacks en attente dans la Callback Queue.

Ce modèle d’exécution permet au moteur JavaScript de gérer de manière efficace des tâches longues ou bloquantes, telles que les entrées/sorties (fichiers et appels réseau), sans bloquer l’exécution du reste du code.

Schéma - Moteur JavaScript, APIs Web, Callback Queue et Event Loop.

Exemple : exécution asynchrone

L’algorithme d’exécution du JavaScript est constitué de 3 étapes clés :

⚙️ Évaluation (synchrone) du script via la Call Stack

Exécuter le script de manière synchrone comme s’il s’agissait d’un corps de fonction.

Chaque fonction est exécutée via la pile d’appel (nommée Call Stack).

  • Si la fonction est synchrone, elle s’exécute classiquement
  • Si la fonction est asynchrone, elle est exécutée par l’intermédiaire des APIs Web (DOM, Timer, Fetch…)

L’exécution continue jusqu’à ce que la pile d’appels soit vide.

⌛ Fonction asynchrone terminée : ajout du callback à la Callback Queue

Dès qu’une fonction asynchrone termine son exécution, son callback est ajouté à la Callback Queue.

🔁 Event Loop : vidage de la Callback Queue vers la Call Stack

Une fois la Call Stack vide, on demande à l’Event Loop de boucler.

  • La micro-tâche asynchrone terminée la plus ancienne de la Callback Queue est déplacée dans la Call Stack pour exécution. Répétition de l’opération jusqu’à ce que la file d’attente des micro-tâches soit vide.
  • La tâche asynchrone terminée la plus ancienne de la Callback Queue est déplacée dans la Call Stack pour exécution.

Mise à jour de l’interface puis retour à l'étape 2 afin de vider la Callback Queue potentiellement remplie entre temps.

Exemple 1

Considérons l’exemple suivant :

copié !
setTimeout(() => {
	console.log('Timeout')
}, 2000)

console.log('Start')
⚙️ Évaluation (synchrone) du script via la Call Stack
  • La fonction setTimeout() est ajoutée dans la Call Stack et demande à l’API Timer d’effectuer un décompte de 2000 millisecondes.
  • Sans attendre la fin du compte à rebours, Start est aussitôt affiché dans la console.
⌛ Fonction timeout (2000) terminée : ajout du callback à la Callback Queue

Au terme du décompte, la fonction de callback du setTimeout() sera placée dans la Callback Queue.

🔁 Event Loop : vidage de la Callback Queue vers la Call Stack

L’Event Loop, bouclant constamment sur cette file d’attente se chargera aussitôt de transmettre à la Call Stack la tâche à effectuer. Timeout est alors affiché dans la console.

Ces scripts s’exécutent en parallèle. On obtient ainsi en sortie :

> Start
> Timeout
Exemple 2

Considérons l’exemple suivant :

copié !
function start() {
	console.log('Start')
	setTimeout(() => {
		console.log('Timeout 1')
	}, 0)
	middle()
}

function middle() {
	console.log('Middle')
}

setTimeout(() => {
	console.log('Timeout 2')
}, 1000)

start()

fetch('https://jsonplaceholder.typicode.com/posts/1')
	.then(response => response.json())
	.then(json => console.log(json))

console.log('End')
⚙️ Évaluation (synchrone) du script via la Call Stack
  • Une fonction setTimeout() est ajoutée dans la Call Stack. Cette dernière va demander à l’API Web Timer d’effectuer un décompte de 1000 millisecondes.
  • La fonction start() est ajoutée dans la Call Stack. Cette dernière va ajouter une seconde fonction setTimeout() dans la Call Stack et demander à l’API Web Timer d’effectuer un décompte de 0 millisecondes. Enfin, elle va ajouter dans la Call Stack la fonction middle() qui viendra à son tour afficher Middle dans la console.
  • Une requête réseau est lancée via l’API Web Fetch.
  • End est aussitôt affiché dans la console.
⌛ Fonction timeout (0) terminée : ajout du callback à la Callback Queue

Au terme du décompte, la fonction de callback du second setTimeout() sera placée dans la Callback Queue.

🔁 Event Loop : vidage de la Callback Queue vers la Call Stack

L’Event Loop transmet à la Call Stack la tâche à effectuer. Timeout 2 est alors affiché dans la console.

⌛ Promesse résolue : ajout du callback à la Callback Queue

L’appel via l’API Web Fetch est terminé. Le callback contenu dans le premier then() est placé dans la Callback Queue.

Comme vous pouvez le constater, un appel fetch() étant considéré comme une micro-tâche, il s’exécute dès que le traitement asynchrone qui le concerne est terminé.

🔁 Event Loop : vidage de la Callback Queue vers la Call Stack

L’Event Loop transmet à la Call Stack le callback de la résolution de la première promesse (défini dans then()). Ce callback ajoute aussitôt dans la Callback Queue un second callback contenu dans le then() suivant. Enfin l’Event Loop transmet à la Call Stack le callback de la résolution de la seconde promesse, affichant dans la console les données du post.

⌛ Fonction timeout (1000) terminée : ajout du callback à la Callback Queue

Au terme du décompte, la fonction de callback du premier setTimeout() sera placée dans la Callback Queue.

🔁 Event Loop : vidage de la Callback Queue vers la Call Stack

L’Event Loop transmet à la Call Stack la tâche à effectuer. Timeout 1 est alors affiché dans la console.

Les différents console.log() apparaîtront dans l’ordre suivant :

> Start
> Middle
> End
> Timeout 1
> Object // Le post
> Timeout 2