Fonctionnement de JavaScript: sous le capot du moteur V8

Aujourd'hui, nous allons regarder sous le capot du moteur V8 de JavaScript et déterminer comment exactement JavaScript est exécuté.

Dans un article précédent, nous avons appris comment le navigateur est structuré et nous avons eu un aperçu de haut niveau de Chromium. Récapitulons un peu pour que nous soyons prêts à plonger ici.

Contexte

Les standards Web sont un ensemble de règles implémentées par le navigateur. Ils définissent et décrivent des aspects du World Wide Web.

Le W3C est une communauté internationale qui développe des standards ouverts pour le Web. Ils s'assurent que tout le monde suit les mêmes directives et ne doit pas prendre en charge des dizaines d'environnements complètement différents.

Un navigateur moderne est un logiciel assez compliqué avec une base de code de dizaines de millions de lignes de code. Il est donc divisé en un grand nombre de modules responsables de différentes logiques.

Et deux des parties les plus importantes d'un navigateur sont le moteur JavaScript et un moteur de rendu.

Blink est un moteur de rendu responsable de l'ensemble du pipeline de rendu, y compris les arbres DOM, les styles, les événements et l'intégration V8. Il analyse l'arborescence DOM, résout les styles et détermine la géométrie visuelle de tous les éléments.

Tout en surveillant en permanence les changements dynamiques via des images d'animation, Blink peint le contenu sur votre écran. Le moteur JS est une grande partie du navigateur - mais nous ne sommes pas encore entrés dans ces détails.

Moteur JavaScript 101

Le moteur JavaScript exécute et compile JavaScript en code machine natif. Chaque navigateur majeur a développé son propre moteur JS: Chrome de Google utilise V8, Safari utilise JavaScriptCore et Firefox utilise SpiderMonkey.

Nous travaillerons particulièrement avec le V8 en raison de son utilisation dans Node.js et Electron, mais d'autres moteurs sont construits de la même manière.

Chaque étape comprendra un lien vers le code qui en est responsable, afin que vous puissiez vous familiariser avec la base de code et poursuivre la recherche au-delà de cet article.

Nous travaillerons avec un miroir de V8 sur GitHub car il fournit une interface utilisateur pratique et bien connue pour naviguer dans la base de code.

Préparer le code source

La première chose que V8 doit faire est de télécharger le code source. Cela peut être fait via un réseau, un cache ou des techniciens de service.

Une fois le code reçu, nous devons le modifier de manière à ce que le compilateur puisse le comprendre. Ce processus est appelé analyse et se compose de deux parties: l'analyseur et l'analyseur lui-même.

Le scanner prend le fichier JS et le convertit en liste des jetons connus. Il existe une liste de tous les jetons JS dans le fichier keywords.txt.

L'analyseur le récupère et crée un arbre de syntaxe abstraite (AST): une représentation arborescente du code source. Chaque nœud de l'arborescence désigne une construction apparaissant dans le code.

Jetons un œil à un exemple simple:

function foo() { let bar = 1; return bar; }

Ce code produira la structure arborescente suivante:

Vous pouvez exécuter ce code en exécutant un parcours de précommande (racine, gauche, droite):

  1. Définissez la foofonction.
  2. Déclarez la barvariable.
  3. Attribuer 1à bar.
  4. Revenez barhors de la fonction.

Vous verrez également VariableProxy- un élément qui relie la variable abstraite à un endroit en mémoire. Le processus de résolution VariableProxyest appelé analyse de la portée .

Dans notre exemple, le résultat du processus serait que tous les VariableProxys pointent vers la même barvariable.

Le paradigme Just-in-Time (JIT)

Généralement, pour que votre code s'exécute, le langage de programmation doit être transformé en code machine. Il existe plusieurs approches pour savoir comment et quand cette transformation peut se produire.

La manière la plus courante de transformer le code consiste à effectuer une compilation à l'avance. Cela fonctionne exactement comme il sonne: le code est transformé en code machine avant l'exécution de votre programme lors de la phase de compilation.

Cette approche est utilisée par de nombreux langages de programmation tels que C ++, Java et autres.

De l'autre côté du tableau, nous avons l'interprétation: chaque ligne du code sera exécutée au moment de l'exécution. Cette approche est généralement adoptée par des langages à typage dynamique comme JavaScript et Python car il est impossible de connaître le type exact avant l'exécution.

Parce que la compilation à l'avance peut évaluer tout le code ensemble, elle peut fournir une meilleure optimisation et finalement produire un code plus performant. L'interprétation, en revanche, est plus simple à implémenter, mais elle est généralement plus lente que l'option compilée.

Pour transformer le code plus rapidement et plus efficacement pour les langages dynamiques, une nouvelle approche a été créée, appelée compilation Just-in-Time (JIT). Il combine le meilleur de l'interprétation et de la compilation.

Tout en utilisant l'interprétation comme méthode de base, V8 peut détecter les fonctions qui sont utilisées plus fréquemment que d'autres et les compiler à l'aide des informations de type des exécutions précédentes.

Cependant, il est possible que le type change. Nous devons désoptimiser le code compilé et revenir à l'interprétation à la place (après cela, nous pouvons recompiler la fonction après avoir obtenu un nouveau retour de type).

Explorons plus en détail chaque partie de la compilation JIT.

Interprète

V8 utilise un interpréteur appelé Ignition. Au départ, il prend un arbre de syntaxe abstraite et génère du code d'octet.

Les instructions de code d'octet ont également des métadonnées, telles que les positions de la ligne source pour le débogage futur. En général, les instructions de code d'octet correspondent aux abstractions JS.

Prenons maintenant notre exemple et générons manuellement le code d'octet:

LdaSmi #1 // write 1 to accumulator Star r0 // read to r0 (bar) from accumulator Ldar r0 // write from r0 (bar) to accumulator Return // returns accumulator

Ignition a quelque chose appelé un accumulateur - un endroit où vous pouvez stocker / lire des valeurs.

L'accumulateur évite d'avoir à pousser et à faire sauter le haut de la pile. C'est également un argument implicite pour de nombreux codes d'octets et contient généralement le résultat de l'opération. Return renvoie implicitement l'accumulateur.

You can check out all the available byte code in the corresponding source code. If you’re interested in how other JS concepts (like loops and async/await) are presented in byte code, I find it useful to read through these test expectations.

Execution

After the generation, Ignition will interpret the instructions using a table of handlers keyed by the byte code. For each byte code, Ignition can look up corresponding handler functions and execute them with the provided arguments.

As we mentioned before, the execution stage also provides the type feedback about the code. Let’s figure out how it’s collected and managed.

First, we should discuss how JavaScript objects can be represented in memory. In a naive approach, we can create a dictionary for each object and link it to the memory.

However, we usually have a lot of objects with the same structure, so it would not be efficient to store lots of duplicated dictionaries.

To solve this issue, V8 separates the object's structure from the values itself with Object Shapes (or Maps internally) and a vector of values in memory.

For example, we create an object literal:

let c = { x: 3 } let d = { x: 5 } c.y = 4

In the first line, it will produce a shape Map[c] that has the property x with an offset 0.

In the second line, V8 will reuse the same shape for a new variable.

After the third line, it will create a new shape Map[c1] for property y with an offset 1 and create a link to the previous shape Map[c] .

In the example above, you can see that each object can have a link to the object shape where for each property name, V8 can find an offset for the value in memory.

Object shapes are essentially linked lists. So if you write c.x, V8 will go to the head of the list, find y there, move to the connected shape, and finally it gets x and reads the offset from it. Then it’ll go to the memory vector and return the first element from it.

As you can imagine, in a big web app you’ll see a huge number of connected shapes. At the same time, it takes linear time to search through the linked list, making property lookups a really expensive operation.

To solve this problem in V8, you can use the Inline Cache (IC).It memorizes information on where to find properties on objects to reduce the number of lookups.

You can think about it as a listening site in your code: it tracks all CALL, STORE, and LOAD events within a function and records all shapes passing by.

The data structure for keeping IC is called Feedback Vector. It’s just an array to keep all ICs for the function.

function load(a) { return a.key; }

For the function above, the feedback vector will look like this:

[{ slot: 0, icType: LOAD, value: UNINIT }]

It’s a simple function with only one IC that has a type of LOAD and value of UNINIT. This means it’s uninitialized, and we don’t know what will happen next.

Let’s call this function with different arguments and see how Inline Cache will change.

let first = { key: 'first' } // shape A let fast = { key: 'fast' } // the same shape A let slow = { foo: 'slow' } // new shape B load(first) load(fast) load(slow)

After the first call of the load function, our inline cache will get an updated value:

[{ slot: 0, icType: LOAD, value: MONO(A) }]

That value now becomes monomorphic, which means this cache can only resolve to shape A.

After the second call, V8 will check the IC's value and it'll see that it’s monomorphic and has the same shape as the fast variable. So it will quickly return offset and resolve it.

The third time, the shape is different from the stored one. So V8 will manually resolve it and update the value to a polymorphic state with an array of two possible shapes.

[{ slot: 0, icType: LOAD, value: POLY[A,B] }]

Now every time we call this function, V8 needs to check not only one shape but iterate over several possibilities.

For the faster code, you can initialize objects with the same type and not change their structure too much.

Note: You can keep this in mind, but don’t do it if it leads to code duplication or less expressive code.

Inline caches also keep track of how often they're called to decide if it’s a good candidate for optimizing the compiler — Turbofan.

Compiler

Ignition only gets us so far. If a function gets hot enough, it will be optimized in the compiler, Turbofan, to make it faster.

Turbofan takes byte code from Ignition and type feedback (the Feedback Vector) for the function, applies a set of reductions based on it, and produces machine code.

As we saw before, type feedback doesn’t guarantee that it won’t change in the future.

For example, Turbofan optimized code based on the assumption that some addition always adds integers.

But what would happen if it received a string? This process is called deoptimization. We throw away optimized code, go back to interpreted code, resume execution, and update type feedback.

Summary

In this article, we discussed JS engine implementation and the exact steps of how JavaScript is executed.

To summarize, let’s have a look at the compilation pipeline from the top.

We’ll go over it step by step:

  1. It all starts with getting JavaScript code from the network.
  2. V8 parses the source code and turns it into an Abstract Syntax Tree (AST).
  3. Based on that AST, the Ignition interpreter can start to do its thing and produce bytecode.
  4. At that point, the engine starts running the code and collecting type feedback.
  5. To make it run faster, the byte code can be sent to the optimizing compiler along with feedback data. The optimizing compiler makes certain assumptions based on it and then produces highly-optimized machine code.
  6. If, at some point, one of the assumptions turns out to be incorrect, the optimizing compiler de-optimizes and goes back to the interpreter.

That’s it! If you have any questions about a specific stage or want to know more details about it, you can dive into source code or hit me up on Twitter.

Further reading

  • “Life of a script” video from Google
  • A crash course in JIT compilers from Mozilla
  • Nice explanation of Inline Caches in V8
  • Great dive in Object Shapes