So funktioniert JavaScript: Unter der Haube der V8-Engine

Heute schauen wir uns die V8-Engine von JavaScript an und finden heraus, wie genau JavaScript ausgeführt wird.

In einem früheren Artikel haben wir erfahren, wie der Browser aufgebaut ist, und einen allgemeinen Überblick über Chromium erhalten. Lassen Sie uns ein wenig zusammenfassen, damit wir bereit sind, hier einzutauchen.

Hintergrund

Webstandards sind eine Reihe von Regeln, die der Browser implementiert. Sie definieren und beschreiben Aspekte des World Wide Web.

W3C ist eine internationale Gemeinschaft, die offene Standards für das Web entwickelt. Sie stellen sicher, dass jeder die gleichen Richtlinien befolgt und nicht Dutzende völlig unterschiedlicher Umgebungen unterstützen muss.

Ein moderner Browser ist eine ziemlich komplizierte Software mit einer Codebasis von mehreren zehn Millionen Codezeilen. Es ist also in viele Module unterteilt, die für unterschiedliche Logik verantwortlich sind.

Und zwei der wichtigsten Teile eines Browsers sind die JavaScript-Engine und eine Rendering-Engine.

Blink ist eine Rendering-Engine, die für die gesamte Rendering-Pipeline verantwortlich ist, einschließlich DOM-Bäumen, Stilen, Ereignissen und V8-Integration. Es analysiert den DOM-Baum, löst Stile auf und bestimmt die visuelle Geometrie aller Elemente.

Während Sie dynamische Änderungen kontinuierlich über Animationsrahmen überwachen, malt Blink den Inhalt auf Ihrem Bildschirm. Die JS-Engine ist ein großer Teil des Browsers - aber wir haben uns noch nicht mit diesen Details befasst.

JavaScript Engine 101

Die JavaScript-Engine führt JavaScript aus und kompiliert es in nativen Maschinencode. Jeder große Browser hat eine eigene JS-Engine entwickelt: Googles Chrome verwendet V8, Safari verwendet JavaScriptCore und Firefox verwendet SpiderMonkey.

Wir werden wegen der Verwendung in Node.js und Electron besonders mit V8 arbeiten, aber andere Motoren werden auf die gleiche Weise gebaut.

Jeder Schritt enthält einen Link zu dem dafür verantwortlichen Code, damit Sie sich mit der Codebasis vertraut machen und die Forschung über diesen Artikel hinaus fortsetzen können.

Wir werden mit einem Spiegel von V8 auf GitHub arbeiten, da dieser eine bequeme und bekannte Benutzeroberfläche zum Navigieren in der Codebasis bietet.

Quellcode vorbereiten

Als erstes muss V8 den Quellcode herunterladen. Dies kann über ein Netzwerk, einen Cache oder Servicemitarbeiter erfolgen.

Sobald der Code empfangen wurde, müssen wir ihn so ändern, dass der Compiler ihn verstehen kann. Dieser Vorgang wird als Parsing bezeichnet und besteht aus zwei Teilen: dem Scanner und dem Parser selbst.

Der Scanner nimmt die JS-Datei und konvertiert sie in die Liste der bekannten Token. Die Datei keywords.txt enthält eine Liste aller JS-Token.

Der Parser nimmt es auf und erstellt einen abstrakten Syntaxbaum (AST): eine Baumdarstellung des Quellcodes. Jeder Knoten des Baums bezeichnet ein im Code vorkommendes Konstrukt.

Schauen wir uns ein einfaches Beispiel an:

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

Dieser Code erzeugt die folgende Baumstruktur:

Sie können diesen Code ausführen, indem Sie eine Vorbestellungsdurchquerung ausführen (root, left, right):

  1. Definieren Sie die fooFunktion.
  2. Deklarieren Sie die barVariable.
  3. Zuweisen 1zu bar.
  4. Kehren Sie baraus der Funktion zurück.

Sie werden auch sehen VariableProxy- ein Element, das die abstrakte Variable mit einer Stelle im Speicher verbindet. Der Auflösungsprozess VariableProxywird als Bereichsanalyse bezeichnet .

In unserem Beispiel würde das Ergebnis des Prozesses alle VariableProxyauf dieselbe barVariable zeigen.

Das Just-in-Time-Paradigma (JIT)

Im Allgemeinen muss die Programmiersprache in Maschinencode umgewandelt werden, damit Ihr Code ausgeführt werden kann. Es gibt verschiedene Ansätze, wie und wann diese Transformation stattfinden kann.

Die häufigste Art, den Code zu transformieren, besteht darin, vorab eine Kompilierung durchzuführen. Es funktioniert genau so, wie es sich anhört: Der Code wird vor der Ausführung Ihres Programms während der Kompilierungsphase in Maschinencode umgewandelt.

Dieser Ansatz wird von vielen Programmiersprachen wie C ++, Java und anderen verwendet.

Auf der anderen Seite der Tabelle haben wir eine Interpretation: Jede Zeile des Codes wird zur Laufzeit ausgeführt. Dieser Ansatz wird normalerweise von dynamisch typisierten Sprachen wie JavaScript und Python verwendet, da es unmöglich ist, den genauen Typ vor der Ausführung zu kennen.

Da durch die frühzeitige Kompilierung der gesamte Code zusammen bewertet werden kann, kann eine bessere Optimierung erzielt und schließlich ein leistungsfähigerer Code erzeugt werden. Auf der anderen Seite ist die Interpretation einfacher zu implementieren, aber normalerweise langsamer als die kompilierte Option.

Um den Code für dynamische Sprachen schneller und effektiver zu transformieren, wurde ein neuer Ansatz namens Just-in-Time (JIT) -Kompilierung entwickelt. Es kombiniert das Beste aus Interpretation und Zusammenstellung.

Während V8 die Interpretation als Basismethode verwendet, kann es Funktionen erkennen, die häufiger als andere verwendet werden, und sie mithilfe von Typinformationen aus früheren Ausführungen kompilieren.

Es besteht jedoch die Möglichkeit, dass sich der Typ ändert. Wir müssen stattdessen kompilierten Code deoptimieren und auf die Interpretation zurückgreifen (danach können wir die Funktion neu kompilieren, nachdem wir eine neue Typrückmeldung erhalten haben).

Lassen Sie uns jeden Teil der JIT-Kompilierung genauer untersuchen.

Dolmetscher

V8 verwendet einen Interpreter namens Ignition. Zunächst wird ein abstrakter Syntaxbaum verwendet und Bytecode generiert.

Bytecode-Anweisungen enthalten auch Metadaten, z. B. Quellzeilenpositionen für zukünftiges Debugging. Im Allgemeinen stimmen Bytecode-Anweisungen mit den JS-Abstraktionen überein.

Nehmen wir nun unser Beispiel und generieren manuell Bytecode dafür:

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

Die Zündung hat einen sogenannten Akkumulator - einen Ort, an dem Sie Werte speichern / lesen können.

Der Akku vermeidet das Drücken und Aufspringen der Oberseite des Stapels. Es ist auch ein implizites Argument für viele Bytecodes und enthält normalerweise das Ergebnis der Operation. Return gibt implizit den Akku zurück.

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