img

Как работает JavaScript: под капотом V8

Сегодня мы посмотрим на движок JavaScript V8 с точки зрения внутреннего устройства и разберемся, как именно выполняются программы JavaScript. 

Справка

Сетевые стандарты – это набор правил, которые реализует браузер. Они определяют и описывают составляющие Всемирной паутины.

W3C – это международное сообщество, которое занимается разработкой открытых стандартов для Интернета. Они следят за тем, чтобы все придерживались одних и тех же рекомендаций, вследствие чего пропадет необходимость поддерживать десятки совершенно разных сред.

Современный браузер – это достаточно мудрёное программное обеспечение с кодовой базой, которая состоит из десятка миллионов строк кода. И потому его, как правило, разбивают на несколько модулей, каждый из которых отвечает за разную логику. 

Две самых важных части браузера – это движок JavaScript и механизм визуализации.

Blink - это механизм визуализации, который отвечает за весь конвейер визуализации, в том числе DOM-деревья, стили, события, интеграцию с V8. Он анализирует DOM-дерево, задает стили и определяет наглядную геометрию всех элементов.

Blink расцвечивает содержимое на вашем экране, постоянно отслеживая динамические изменения при помощи кадров анимации. Движок JS – это большая часть браузера, но мы еще эту тему не разбирали.

Движок JavaScript 101

Движок JavaScript выполняет и компилирует код JavaScript в собственный машинный код. У каждого крупного браузера есть свой собственный разработанный движок JS: Google Chrome использует V8, Safari – JavaScriptCore, а Firefox – SpiderMonkey. 

Мы будем работать исключительно с V8, так как он используется в Node.js и Electron, но при этом нужно понимать, что другие движки построены аналогично. 

На каждом этапе будет присутствовать ссылка на код, ответственный за этот конкретный этап, чтобы у вас была возможность ознакомиться с кодовой базой и продолжить работу за рамками данной статьи. 

Мы будем работать с зеркалом (зеркальным сервером) V8 на GitHub. Оно предоставляет удобный и хорошо знакомый пользовательский интерфейс для навигации по кодовой базе.

Подготовка исходного кода

Для начала V8 должен загрузить исходный код. Это можно сделать через сеть, из кэша или в этом вам может помочь квалифицированный работник. 

После того, как вы получите код, нам необходимо изменить его так, чтобы его смог понять компилятор. Этот процесс называется синтаксическим анализом. В нем участвуют два компонента: сканер и сам синтаксический анализатор. 

Сканер берет файл JS и конвертирует его в список известных токенов. Список всех токенов JS есть в файле keywords.txt.

Синтаксический анализатор берет этот список и формирует дерево абстрактного синтаксического анализа (AST – Abstract Syntax Tree). AST – это древовидное представление исходного кода. Каждый узел дерева – это конструкция, которая встречается в коде. 

Давайте посмотрим на простой пример:

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

Этот код образует следующее дерево:

Пример AST-дерева

Вы можете выполнить этот код, выполнив обход дерева в ширину (корень, левая ветка, правая ветка):

  1. Определите функцию foo.
  2. Объявите переменную bar.
  3. Присвойте значение 1 переменной bar.
  4. Верните переменную bar как результат работы функции. 

Вы также увидите VariableProxy. Это элемент, который связывает абстрактную переменную с местом в памяти. Процесс разрешения VariableProxy называется анализом области видимости переменных.  

В нашем примере в качестве результата мы получим все VariableProxy, которые указывают на одну и ту же переменную bar.

JIT-парадигма

Согласно общим правилам, для того, чтобы выполнить код, язык программирования нужно преобразовать в машинный код. Есть несколько способов, как это можно сделать. 

Самый распространённый способ преобразования кода – это компиляция перед выполнением. Работает это так же, как и звучит: код преобразуется в машинный непосредственно перед выполнением программы на этапе компиляции. 

Такой подход используют многие языки программирования, например, C++, Java и другие.

С другой стороны, у нас есть интерпретирование: каждая строка кода выполняется непосредственно по время выполнения программы. Такой подход, как правило, используют языки с динамической типизацией, например, JavaScript и Python, так как до выполнения точный тип данных неизвестен. 

Так как компиляция перед выполнением позволяет оценить весь код сразу, она может обеспечить лучшую оптимизацию, а, соответственно, и получить более производительный код. Но при этом интерпретирование проще с точки зрения реализации, но, как правило, медленнее, чем вариант с компиляцией.

Для того, чтобы быстрее и эффективнее преобразовывать код, написанный на динамических языках программирования, был разработан новый подход, который называется JIT-компиляцией (Just-In-Time-компиляция – компиляция «на лету»). Этот подход сочетает в себе лучшие качества интерпретирования и компиляции.

V8 можнет использовать интерпретирование в качестве основного метода и находить функции, которые используются чаще, чем остальные, и компилировать их, используя информацию о типах из предыдущих запусков программы. 

Однако существует вероятность того, что тип изменится. Вместе с тем, нам нужно деоптимизировать скомпилированный код и повторно запустить интерпретирование (после этого, когда мы получим обратную связь нового типа, мы можем снова скомпилировать функцию).

Давайте изучим каждую составляющую JIT-компиляции подробнее. 

Интерпретатор

V8 использует интерпретатор, который называется Ignition. Прежде всего он берет дерево абстрактного синтаксического анализа и генерирует байт-код.

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

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

LdaSmi #1 // write 1 to accumulator {записывает 1 в аккумулятор}
Star r0   // read to r0 (bar) from accumulator {считывает (bar) в r0 из аккумулятора}
Ldar r0   // write from r0 (bar) to accumulator {записывает (bar) из r0 в аккумулятор}
Return    // returns accumulator (возвращает аккумулятор)

В Ignition есть нечто, что называется аккумулятором – место, где вы можете хранить или откуда вы можете считывать значения. 

Аккумулятор позволяет избежать ситуаций, когда необходимо добавлять и вытаскивать вершину стека. Он также является неявно определенным аргументом для многих байт-кодов и, как правило, хранит результат операции. Return неявно возвращает аккумулятор.

Весь доступный байт-код вы можете извлечь из соответствующего исходного кода

Выполнение

После того, как будет сгенерирован байт-код, Ignition будет интерпретировать команды с помощью таблицы обработчиков с доступом по ключу в виде байт-кода. Для каждого байт-кода Ignition может найти соответствующие функции-обработчики и выполнить их с предлагаемыми аргументами.

Как уже упоминалось ранее, этап выполнения также предоставляет обратную связь типа кода. Давайте разберемся, как это все получается и как этим управлять. 

Для начала, обсудим, как объекты JavaScript могу быть представлены в памяти. При самом простом подходе мы можем создать словарь для каждого объекта и связать его с памятью.

Первый подход к хранению объектов

Однако, как правило, есть много объектов с одинаковой структурой, поэтому хранить большое количество одинаковых словарей будет неэффективно. 

Для того, чтобы избежать таких ситуаций, V8 отделяет структуру объекта от самих значений с помощью форм объектов (Map) и вектора значений в памяти. 

Например, мы создаем объектный литерал:

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

В первой строке он создаст форму Map[c], которая имеет свойство x со смещением 0.

Во второй строке V8 будет повторно использовать ту же форму для новой переменной. 

После третьей строки он создаст новую форму Map[c1], у которой будет свойство y со смещением 1, а также ссылку на предыдущую форму Map[c].

Пример форм объектов

В приведенном выше примере каждый объект может иметь ссылку на форму объекта, где для каждого свойства V8 может найти смещение для значения, хранящегося в памяти.

Формы объектов – это, по сути, связные списки. То есть, если вы напишите c.x, то V8 перейдет в начало списка, найдет там y, перейдет к связанной форме и, наконец, получит x и считает из него смещение. После чего он перейдет к вектору памяти и вернет из него первый элемент. 

Как вы уже могли понять, в большом веб-приложении будет огромное количество таких связных форм. И при этом на выполнение поиска по связному списку затрачивается линейное время, что делает поиск свойств достаточно затратной операцией. 

Для того, чтобы решить данную проблему в V8, вы можете использовать встроенный кэш (IC – Inline Cache). В нем сохраняется информация о том, где можно найти свойства объектов. Таким образом, количество поисков сокращается. 

Вы можете представить, что это некое прослушивающее устройство в вашем коде: он отслеживает все события CALL, STORE и LOAD внутри функции и записывает все проходящие через нее формы.

Структура данных, которая используется для хранения IC, называется вектором обратной связи. Это просто массив, в котором хранятся все IC функции. 

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

Для функции выше вектор обратной связи будет выглядеть следующим образом: 

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

Это простая функция, у которой есть только один IC, который имеет тип LOAD и значение UNINIT. Это значит, что он не был инициализирован, и мы не знаем, что будет происходить дальше. 

Давайте вызовем эту функцию, передав ей другие аргументы, и посмотрим, как изменится встроенный кэш.

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)

После первого вызова функции load наш встроенный кэш получит обновленное значение:

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

Теперь это значение стало мономорфным, а это значит, то кэш разрешается только в форму А. 

После второго вызова V8 проверит значение IC и увидит, что оно мономорфно и имеет ту же форму, что и переменная fast. В связи с чем, он тут же вернет смещение и разрешит его. 

При третьем вызове форма будет отличаться от сохраненной. Поэтому V8 вручную разрешит его и обновит значение до «полиморфный» с помощью массива из двух возможных форм. 

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

Теперь при каждом вызове этой функции V8 должен проверять не одну форму, а перебрать несколько вариантов. 

Для того, чтобы ускорить ваш код, вы можете инициализировать объекты одинаковыми типами и не слишком усердствовать, меняя их структуру. 

Примечание: возьмите это на заметку, но если это может привести к дублированию кода или если ваш код станет менее выразительным, то не делайте этого. 

Встроенные кэши также отслеживают то, как часто они вызываются, чтобы понять, хорошо ли Turbofan справляется с оптимизацией компилятора. 

Компилятор

Ignition помогает только до определенного времени. Если функция становится достаточно медленной, то она оптимизируется в компиляторе Turbofan. 

Turbofan берет байт-код из Ignition и обратную связь типа (вектор обратной связи) функции, применяет набор преобразований на его основе и выдает машинный код.

И как мы уже видели до этого, обратная связь типа не всегда гарантирует, что функция останется неизменной.

Например, код, который был оптимизирован с помощью Turbofan, основан на том предположении, что некоторая операция сложения всегда прибавляет целые числа. 

Но что будет, если функция получит строку? Этот процесс называется деоптимизацией. Мы отбрасываем оптимизированный код, возвращаемся к интерпретируемому, возобновляем выполнение и обновляем обратную связь типа. 

Подведем итог

В этой статье мы рассмотрели реализацию движка JS и четкие этапы выполнения кода на JavaScript.

Подведем итог и взглянем на процесс компиляции, так сказать, сверху.

Общая схема V8

Рассмотрим процесс шаг за шагом:

  1. Все начинается с того, что мы получаем код JavaScript из сети.
  2. V8 анализирует код с точки зрения синтаксиса и превращает его в дерево абстрактного синтаксического анализа (AST).
  3. Беря за основу это дерево AST, интерпретатор Ignition может приступить к своей работе и начать формировать байт-код.
  4. На этом этапе движок запускает код и собирает обратную связь типов.
  5. Чтобы ускорить работу, байт-код можно отправить оптимизирующему компилятору вместе с данными обратной связи. Оптимизирующий компилятор создает на его основе некие предположения, после чего выдает высокооптимизированный машинный код. 
  6. Если в какой-то момент оказывается, что одно из предположений неверно, то оптимизирующий компилятор деоптимизирует код и возвращается к интерпретатору.
Ссылка
скопирована
Программирование
Скидка 25%
Фронтенд-разработчик с нуля
Погрузитесь в мир веб-разработки, освоив основные инструменты работы: HTML, CSS, JavaScript. Научитесь работать с дизайн-макетами и адаптивной версткой, сверстаете свои первые страницы и поймете, как строить карьерный трек в ИТ.
Получи бесплатный
вводный урок!
Пожалуйста, укажите корректный e-mail
отправили вводный урок на твой e-mail!
Получи все материалы в telegram и ускорь обучение!
img
Еще по теме:
img
За последние годы микрослужбы прошли путь от обычного переоцененного модного словечка до вещи, которую вы, как специалист по про
img
Введение Резидентные базы данных (или хранилища в памяти) по большей части делают упор на хранилище данных в памяти, а не на жес
img
  Многие люди рассуждают так: «зачем, ну зачем мне изучать еще один язык программирования?» Для них это лишнее, и они стараютс
img
Введение Объекты в Kubernetes – это базовые постоянные сущности, которые описывают состояние кластера Kubernetes. Модули – это э
img
  Довольно часто мы встречаемся с компонентами архитектуры программного обеспечения, которые являются частью любой системы, но п
img
  Ключевые отличия между JDK, JRE и JVM: JDK – это набор средств для разработки программного обеспечения, JRE – это програ
Комментарии
ЛЕТНИЕ СКИДКИ
40%
50%
60%
До конца акции: 30 дней 24 : 59 : 59