Як працює JavaScript: під капотом двигуна V8

Сьогодні ми заглянемо під капот двигуна V8 JavaScript і з’ясуємо, як саме виконується JavaScript.

У попередній статті ми дізналися про структуру браузера та отримали високорівневий огляд Chromium. Давайте трохи підсумуємо, щоб ми були готові зануритися сюди.

Передумови

Веб-стандарти - це набір правил, які реалізує браузер. Вони визначають і описують аспекти Всесвітньої павутини.

W3C - це міжнародна спільнота, яка розробляє відкриті стандарти для Інтернету. Вони стежать за тим, щоб усі дотримувались однакових вказівок і не мусили підтримувати десятки абсолютно різних середовищ.

Сучасний браузер - це досить складна частина програмного забезпечення з кодовою базою в десятки мільйонів рядків коду. Тому він розділений на безліч модулів, відповідальних за різну логіку.

І дві найважливіші частини браузера - це механізм JavaScript та механізм візуалізації.

Blink - це механізм візуалізації, який відповідає за весь конвеєр рендерингу, включаючи дерева DOM, стилі, події та інтеграцію V8. Він аналізує дерево DOM, вирішує стилі та визначає візуальну геометрію всіх елементів.

Постійно відстежуючи динамічні зміни за допомогою анімаційних кадрів, Blink малює вміст на екрані. Механізм JS - це велика частина браузера, але ми ще не вникли в ці деталі.

JavaScript Engine 101

Механізм JavaScript виконує та компілює JavaScript у власний машинний код. Кожен основний браузер розробив власний движок JS: Chrome Google використовує V8, Safari використовує JavaScriptCore, а Firefox використовує SpiderMonkey.

Ми особливо будемо працювати з V8 через його використання в Node.js та Electron, але інші двигуни побудовані так само.

Кожен крок міститиме посилання на код, відповідальний за нього, тому ви зможете ознайомитися з кодовою базою та продовжити дослідження за цією статтею.

Ми будемо працювати з дзеркалом V8 на GitHub, оскільки воно забезпечує зручний та відомий інтерфейс для навігації по кодовій базі.

Підготовка вихідного коду

Перше, що потрібно зробити V8, - це завантажити вихідний код. Це можна зробити через мережу, кеш чи службовців.

Після отримання коду нам потрібно змінити його так, щоб компілятор міг зрозуміти. Цей процес називається синтаксичним розбором і складається з двох частин: сканера та самого синтаксичного аналізатора.

Сканер бере файл JS і перетворює його у список відомих токенів. У файлі keywords.txt є список усіх маркерів JS.

Синтаксичний аналізатор бере його та створює абстрактне дерево синтаксису (AST): дерево-подання вихідного коду. Кожен вузол дерева позначає конструкцію, що зустрічається в коді.

Давайте подивимось на простий приклад:

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

Цей код створить таку деревоподібну структуру:

Ви можете виконати цей код, виконавши обхід попереднього замовлення (корінь, ліворуч, праворуч):

  1. Визначте fooфункцію.
  2. Оголосіть barзмінну.
  3. Призначають 1в bar.
  4. Повернення barз функції.

Ви також побачите VariableProxy- елемент, який з'єднує абстрактну змінну з місцем у пам'яті. Процес вирішення VariableProxyназивається аналізом сфери .

У нашому прикладі результатом процесу буде все, VariableProxyщо вказує на ту саму barзмінну.

Парадигма "Вчасно" (JIT)

Як правило, для виконання вашого коду мову програмування потрібно перетворити на машинний код. Існує кілька підходів до того, як і коли може відбутися ця трансформація.

Найпоширеніший спосіб трансформації коду - це виконання компіляції, що випереджає час. Це працює точно так, як звучить: код перетворюється на машинний код перед виконанням вашої програми на етапі компіляції.

Цей підхід використовується багатьма мовами програмування, такими як C ++, Java та іншими.

З іншого боку таблиці ми маємо інтерпретацію: кожен рядок коду буде виконаний під час виконання. Зазвичай такий підхід застосовують динамічно набрані мови, такі як JavaScript та Python, оскільки неможливо точно знати тип перед виконанням.

Оскільки заздалегідь компіляція може оцінити весь код разом, вона може забезпечити кращу оптимізацію і врешті-решт створити більш продуктивний код. Інтерпретація, з іншого боку, простіша у реалізації, але, як правило, вона повільніша, ніж скомпільований варіант.

Для швидшого та ефективнішого перетворення коду для динамічних мов був створений новий підхід, який називається компіляцією Just-in-Time (JIT). Він поєднує в собі найкраще з інтерпретації та компіляції.

Використовуючи інтерпретацію як базовий метод, V8 може виявляти функції, які використовуються частіше за інші, та компілювати їх, використовуючи інформацію про тип із попередніх виконань.

Однак існує ймовірність того, що тип може змінитися. Натомість нам потрібно деоптимізувати скомпільований код і повернутися до інтерпретації (після цього ми можемо перекомпілювати функцію після отримання відгуку про новий тип).

Давайте розглянемо кожну частину компіляції JIT більш докладно.

Перекладач

V8 використовує інтерпретатор під назвою запалювання. Спочатку він бере абстрактне дерево синтаксису і генерує байт-код.

Інструкції байт-коду також містять метадані, такі як позиції вихідного рядка для подальшої налагодження. Як правило, інструкції байт-коду відповідають абстракціям JS.

Тепер давайте візьмемо наш приклад і згенеруємо байт-код для нього вручну:

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

Запалювання має щось, що називається акумулятором - місце, де ви можете зберігати / читати значення.

Акумулятор уникає необхідності штовхати і виштовхувати верх стопки. Це також неявний аргумент для багатьох байтових кодів і, як правило, містить результат операції. Повернення неявно повертає накопичувач.

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