HTML: Markup language
CSS: Styling language
JavaScript: Scripting language
Web APIs: Programming interfaces
All web technology
Learn web development
Discover our tools
Get to know MDN better
Cette page a été traduite à partir de l'anglais par la communauté. Vous pouvez contribuer en rejoignant la communauté francophone sur MDN Web Docs.
View in English Always switch to English
Cette page présente l'infrastructure de base de l'environnement d'exécution JavaScript. Le modèle est principalement théorique et abstrait, sans aucun détail spécifique à une plateforme ou à une implémentation. Les moteurs JavaScript modernes optimisent fortement la sémantique décrite ici.
Cette page est une référence. Elle suppose que vous connaissez déjà le modèle d'exécution d'autres langages de programmation, comme C ou Java. Elle fait de nombreuses références à des concepts existants dans les systèmes d'exploitation et les langages de programmation.
L'exécution JavaScript nécessite la coopération de deux logiciels : le moteur JavaScript et l'environnement hôte.
Le moteur JavaScript implémente le langage ECMAScript (JavaScript), fournissant les fonctionnalités de base. Il prend le code source, l'analyse et l'exécute. Cependant, pour interagir avec le monde extérieur, produire une sortie utile, accéder à des ressources externes ou mettre en œuvre des mécanismes liés à la sécurité ou aux performances, il faut des mécanismes supplémentaires fournis par l'environnement hôte. Par exemple, le DOM HTML est l'environnement hôte lorsque JavaScript s'exécute dans un navigateur web. Node.js est un autre environnement hôte qui permet d'exécuter JavaScript côté serveur.
Bien que cette page se concentre principalement sur les mécanismes définis dans ECMAScript, elle aborde parfois des mécanismes définis dans la spécification HTML, souvent imités par d'autres environnements hôtes comme Node.js ou Deno. Cela permet de donner une vision cohérente du modèle d'exécution JavaScript tel qu'il est utilisé sur le web et au-delà.
Dans la spécification JavaScript, chaque exécuteur autonome de JavaScript est appelé un agent, qui maintient ses propres structures pour l'exécution du code :
SharedArrayBuffer
Ce sont trois structures de données distinctes qui gèrent des informations différentes. Nous présenterons la queue et la pile plus en détail dans les sections suivantes. Pour en savoir plus sur l'allocation et la libération de la mémoire du tas, voir gestion de la mémoire.
Chaque agent est analogue à un thread (ou fil d'exécution) (l'implémentation sous-jacente peut ou non être un vrai thread système). Chaque agent peut posséder plusieurs realms (qui correspondent 1-à-1 à des objets globaux) pouvant s'accéder mutuellement de façon synchrone, et doit donc s'exécuter dans un seul thread. Un agent possède aussi un modèle mémoire unique, indiquant s'il est petit-boutiste (little-endian en anglais), s'il peut être bloqué de façon synchrone, si les opérations atomiques sont sans verrou, etc.
Un agent sur le web peut être l'un des suivants :
Window
document.domain
DedicatedWorkerGlobalScope
SharedWorkerGlobalScope
ServiceWorkerGlobalScope
WorkletGlobalScope
En d'autres termes, chaque worker crée son propre agent, tandis qu'une ou plusieurs fenêtres peuvent appartenir au même agent — généralement un document principal et ses iframes de même origine. Dans Node.js, un concept similaire appelé worker threads (angl.) existe.
Le schéma ci-dessous illustre le modèle d'exécution des agents :
Chaque agent possède un ou plusieurs realms. Chaque morceau de code JavaScript est associé à un realm lors de son chargement, ce qui reste vrai même s'il est appelé depuis un autre realm. Un realm contient les informations suivantes :
Array
Array.prototype
globalThis
Sur le web, le realm et l'objet global correspondent 1-à-1. L'objet global est soit un Window, soit un WorkerGlobalScope, soit un WorkletGlobalScope. Par exemple, chaque iframe s'exécute dans un realm différent, même s'il peut être dans le même agent que la fenêtre parente.
WorkerGlobalScope
iframe
Les realms sont généralement mentionnés lorsqu'on parle de l'identité des objets globaux. Par exemple, on a besoin de méthodes comme Array.isArray() ou Error.isError(), car un tableau construit dans un autre realm aura un prototype différent de Array.prototype dans le realm courant, donc instanceof Array retournera à tort false.
Array.isArray()
Error.isError()
instanceof Array
false
Commençons par l'exécution synchrone du code. Chaque tâche commence en appelant son callback associé. Le code à l'intérieur de ce callback peut créer des variables, appeler des fonctions ou sortir. Chaque fonction doit garder la trace de ses propres environnements de variables et de l'endroit où retourner. Pour cela, l'agent a besoin d'une pile pour suivre les contextes d'exécution. Un contexte d'exécution (ou stack frame) est la plus petite unité d'exécution. Il contient les informations suivantes :
var
let
const
function
class
#foo
this
Imaginons un programme constitué d'une seule tâche définie par le code suivant :
function toto(b) { const a = 10; return a + b + 11; } function tata(x) { const y = 3; return toto(x * y); } const truc = tata(7); // assigne 42 à truc
toto
tata
truc
7
x
y
x * y
b
a
a + b + 11
toto(x * y)
tata(7)
Quand un cadre est dépilé, il n'est pas forcément perdu pour toujours, car il arrive qu'on doive y revenir. Par exemple, considérons une fonction génératrice :
function* gen() { console.log(1); yield; console.log(2); } const g = gen(); g.next(); // affiche 1 g.next(); // affiche 2
Dans ce cas, appeler gen() crée d'abord un contexte d'exécution qui est suspendu — aucun code à l'intérieur de gen n'est encore exécuté. Le générateur g sauvegarde ce contexte d'exécution en interne. Le contexte d'exécution courant reste celui du point d'entrée. Quand on appelle g.next(), le contexte d'exécution de gen est empilé, et le code à l'intérieur de gen s'exécute jusqu'à l'expression yield. Ensuite, le contexte d'exécution du générateur est suspendu et retiré de la pile, ce qui rend la main au point d'entrée. Quand on appelle g.next() à nouveau, le contexte d'exécution du générateur est ré-empilé, et le code à l'intérieur de gen reprend là où il s'était arrêté.
gen()
gen
g
g.next()
yield
Un mécanisme défini dans la spécification est l'appel en queue propre (PTC). Un appel de fonction est un appel en queue si l'appelant ne fait rien après l'appel sauf retourner la valeur :
function f() { return g(); }
Dans ce cas, l'appel à g est un appel en queue. Si un appel de fonction est en position de queue, le moteur doit supprimer le contexte d'exécution courant et le remplacer par celui de l'appel en queue, au lieu d'empiler un nouveau cadre pour l'appel à g(). Cela signifie que la récursion terminale n'est pas soumise aux limites de taille de pile :
g()
function factoriel(n, acc = 1) { if (n <= 1) return acc; return factoriel(n - 1, n * acc); }
En pratique, supprimer le cadre courant pose des problèmes de débogage, car si g() lève une erreur, f n'est plus sur la pile et n'apparaît pas dans la trace. Actuellement, seul Safari (JavaScriptCore) implémente PTC, et ils ont inventé une infrastructure spécifique (angl.) pour résoudre ce problème de débogabilité.
f
Un autre phénomène intéressant lié à la portée des variables et aux appels de fonction est celui des fermetures. Lorsqu'une fonction est créée, elle mémorise aussi en interne les liaisons de variables du contexte d'exécution courant. Ces liaisons peuvent alors survivre au contexte d'exécution.
let f; { let x = 10; f = () => x; } console.log(f()); // affiche 10
Un agent est un thread, ce qui signifie que l'interpréteur ne peut traiter qu'une instruction à la fois. Quand le code est entièrement synchrone, cela fonctionne car on peut toujours avancer. Mais si le code doit effectuer une action asynchrone, on ne peut pas progresser tant que cette action n'est pas terminée. Cependant, cela nuirait à l'expérience utilisateur si cela bloquait tout le programme — la nature de JavaScript comme langage de script web exige qu'il soit non bloquant. Ainsi, le code qui gère la fin d'une action asynchrone est défini comme un callback. Ce callback définit une tâche, qui est placée dans une queue de tâches — ou, en HTML, une boucle d'événement — une fois l'action terminée.
À chaque fois, l'agent prend une tâche dans la queue et l'exécute. Lorsqu'une tâche est exécutée, elle peut en créer d'autres, qui sont ajoutées à la fin de la queue. Les tâches peuvent aussi être ajoutées par la complétion de mécanismes asynchrones de la plateforme, comme les timers, les opérations d'entrée/sortie ou les événements. Une tâche est considérée comme terminée quand la pile est vide ; la tâche suivante est alors prise dans la queue. Les tâches ne sont pas forcément traitées avec la même priorité — par exemple, les boucles d'événement HTML séparent les tâches en deux catégories : tâches et micro-tâches. Les micro-tâches ont une priorité plus élevée et la queue de micro-tâches est vidée avant que la queue des tâches ne soit traitée. Pour plus d'informations, voir le guide HTML sur les micro-tâches. Si la queue de tâches est vide, l'agent attend que d'autres tâches soient ajoutées.
Chaque tâche est traitée complètement avant toute autre tâche. Cela offre des propriétés intéressantes pour raisonner sur votre programme, notamment le fait que lorsqu'une fonction s'exécute, elle ne peut pas être interrompue et s'exécutera entièrement avant tout autre code (et pourra modifier les données manipulées). Cela diffère de C, par exemple, où si une fonction s'exécute dans un thread, elle peut être arrêtée à tout moment par le système d'exécution pour exécuter un autre code dans un autre thread.
Par exemple :
const promise = Promise.resolve(); let i = 0; promise.then(() => { i += 1; console.log(i); }); promise.then(() => { i += 1; console.log(i); });
Dans cet exemple, on crée une promesse déjà résolue, ce qui signifie que tout retour d'appel attaché sera immédiatement planifié comme tâche. Les deux retours d'appels semblent provoquer une condition de course, mais en réalité, le résultat est totalement prévisible : 1 et 2 seront affichés dans l'ordre. En effet, chaque tâche s'exécute jusqu'au bout avant que la suivante ne soit lancée, donc l'ordre global est toujours i += 1; console.log(i); i += 1; console.log(i); et jamais i += 1; i += 1; console.log(i); console.log(i);.
1
2
i += 1; console.log(i); i += 1; console.log(i);
i += 1; i += 1; console.log(i); console.log(i);
Un inconvénient de ce modèle est que si une tâche prend trop de temps à s'exécuter, l'application web ne peut plus traiter les interactions utilisateur comme les clics ou le défilement. Le navigateur atténue cela avec le message « un script met trop de temps à s'exécuter ». Une bonne pratique consiste à garder le traitement des tâches court et, si possible, à découper une tâche problématique en plusieurs tâches.
Une autre garantie importante offerte par le modèle de boucle d'événement est que l'exécution JavaScript n'est jamais bloquante. La gestion d'opérations d'entrée/sortie se fait généralement via des événements et des retours d'appels, donc quand l'application attend le résultat d'une requête IndexedDB ou d'un appel à fetch(), elle peut toujours traiter d'autres éléments comme les saisies utilisateur. Le code qui s'exécute après la fin d'une action asynchrone est toujours fourni sous forme de callback (par exemple, le gestionnaire then(), le callback de setTimeout(), ou le gestionnaire d'événement), qui définit une tâche à ajouter à la queue une fois l'action terminée.
fetch()
then()
setTimeout()
Bien sûr, la garantie de « jamais bloquant » suppose que l'API de la plateforme soit asynchrone, mais il existe quelques exceptions historiques comme alert() ou les XHR synchrones. Il est recommandé de les éviter pour garantir la réactivité de l'application.
alert()
Plusieurs agents peuvent communiquer via le partage de mémoire, formant un groupe d'agents. Les agents sont dans le même groupe si et seulement s'ils peuvent partager la mémoire. Il n'existe aucun mécanisme intégré pour que deux groupes d'agents échangent des informations, ils peuvent donc être considérés comme des modèles d'exécution complètement isolés.
Lors de la création d'un agent (par exemple en lançant un worker), certains critères déterminent s'il appartient au même groupe que l'agent courant ou si un nouveau groupe est créé. Par exemple, les paires d'objets globaux suivantes sont chacune dans le même groupe d'agents et peuvent donc partager la mémoire :
Les paires d'objets globaux suivantes ne sont pas dans le même groupe d'agents et ne peuvent donc pas partager la mémoire :
Pour l'algorithme exact, voir la spécification HTML (angl.).
Comme mentionné plus haut, les agents communiquent via le partage de mémoire. Sur le web, la mémoire est partagée via la méthode postMessage(). Le guide Utiliser les web workers donne un aperçu de ce mécanisme. En général, les données sont transmises uniquement par valeur (via le clonage structuré), ce qui évite toute complication de concurrence. Pour partager la mémoire, il faut transmettre un objet SharedArrayBuffer, qui peut être accédé simultanément par plusieurs agents. Une fois que deux agents partagent l'accès à la même mémoire via un SharedArrayBuffer, ils peuvent synchroniser leurs exécutions via l'objet Atomics.
postMessage()
Atomics
Il existe deux façons d'accéder à la mémoire partagée : via un accès mémoire normal (non atomique) et via un accès mémoire atomique. Ce dernier est séquentiellement cohérent (angl.) (c'est-à-dire qu'il existe un ordre total strict des événements accepté par tous les agents du groupe), tandis que le premier n'est pas ordonné (aucun ordre n'est garanti) ; JavaScript ne fournit pas d'opérations avec d'autres garanties d'ordre.
La spécification donne les recommandations suivantes pour les développeur·euse·s travaillant avec la mémoire partagée :
Il est recommandé d'écrire des programmes sans conditions de concurrence (data race free), c'est-à-dire de faire en sorte qu'il soit impossible d'avoir des opérations non atomiques concurrentes sur la même zone mémoire. Les programmes sans conditions de concurrence ont une sémantique d'entrelacement où chaque étape de l'évaluation de chaque agent s'entrelace avec les autres. Pour ces programmes, il n'est pas nécessaire de comprendre les détails du modèle mémoire. Ces détails n'apportent généralement pas d'intuition utile pour mieux écrire de l'ECMAScript. Plus généralement, même si un programme n'est pas data race free, il peut avoir un comportement prévisible, tant que les opérations atomiques ne sont pas impliquées dans des courses et que les opérations en course ont toutes la même taille d'accès. Le moyen le plus simple d'éviter que les atomiques soient impliquées dans des courses est de s'assurer que différentes cellules mémoire sont utilisées pour les opérations atomiques et non atomiques, et que des accès atomiques de tailles différentes ne sont pas utilisés sur les mêmes cellules en même temps. En pratique, le programme doit traiter la mémoire partagée comme fortement typée autant que possible. On ne peut toujours pas dépendre de l'ordre et du timing des accès non atomiques en course, mais si la mémoire est traitée comme fortement typée, les accès concurrents ne « déchirent » pas (les bits de leurs valeurs ne seront pas mélangés).
Lorsque plusieurs agents coopèrent, la garantie jamais bloquant ne s'applique pas toujours. Un agent peut être bloqué ou mis en pause en attendant qu'un autre agent effectue une action. Cela diffère de l'attente d'une promesse dans le même agent, car cela bloque tout l'agent et n'autorise aucun autre code à s'exécuter entre-temps — autrement dit, il ne peut pas progresser.
Pour éviter les interblocages (deadlocks), il existe des restrictions strictes sur les moments et les agents qui peuvent être bloqués.
Le groupe d'agents garantit un certain niveau d'intégrité sur l'activité de ses agents, en cas de pause ou de terminaison externe :