От JavaScript к машинному коду

Как разработчики JavaScript, мы пишем код, удобочитаемый для человека — он не может быть запущен браузером как есть, его нужно перевести в машинный код. Мы собираемся проследить основные этапы этого процесса, поскольку они происходят в двигателе V8 — движке Google JavaScript, который используется Chrome и Node.js (и другими браузерами на основе Chromium).

Пролог — Получение текста скрипта

Прежде чем начнется сам процесс, необходимо загрузить код JS — как только парсер браузера достигнет соответствующего тега script. Это можно сделать через сеть, из кэша или через сервис-воркер. На данный момент код JS представлен в виде потока байтов.

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

function multByFive(num) {
  return num * 5;
}

Первый шаг — Скрипт текста в AST

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

Первый этап — это лексический разбор байтов (выполняемый лексером) и их токенизация (создание токенов, представляющих полученные байты). Этот этап также ищет синтаксические ошибки, когда байты правильно разбираются в токены, но порядок токенов не соответствует никакому допустимому правилу синтаксиса. Например, ключевое слово function и semicolon являются допустимыми токенами, но function; не является допустимым синтаксисом JS:

Если мы посмотрим на наш пример, он преобразуется в следующие токены:

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

Синтаксический анализатор берет токены и создает AST (абстрактное синтаксическое дерево), которое на следующем шаге интерпретируется в байтовый код. Кроме того, он создает области в соответствии с заданным кодом, которые включают любое объявление переменной или ссылку на переменную, которые встречаются в самой области. Позже синтаксический анализатор выполняет анализ области и связывает ссылки переменных с правильными объявлениями переменных, проходя по цепочке областей видимости. Наконец, синтаксический анализатор также находит любые синтаксические ошибки.

Базовый AST для нашего примера кода может выглядеть так:

Предварительный синтаксический анализатор работает в два раза быстрее синтаксического анализатора — он ищет ограниченный набор ошибок (не так много, как синтаксический анализатор), однако не создает AST. In также инициализирует области видимости, но не смотрит на различные ссылки или объявления переменных.

Важно отметить разбор внутренних функций. Всякий раз, когда функция анализируется или предварительно анализируется, любые внутренние функции также подвергаются предварительному анализу. Однако из-за концепции замыканий препарсер должен отметить все ссылки на переменные во внутренней функции и обязательно сохранить их для последующего использования. Это делает пре-парсер медленнее, чем при разборе «внешних» функций, но все же быстрее, чем полный парсер.

Второй шаг — AST в байт-код

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

На этом этапе важно отметить, что JS — это язык с динамической типизацией, а это означает, что переменная может быть разного типа в разное время, и, в частности, объекты, передаваемые в функции, не обязательно должны иметь одни и те же свойства каждый раз. По этой причине механизм JS не может быть уверен, какого типа будет объект (и как получить доступ к свойствам объекта), когда он генерирует байт-код. Однако он (оптимистично) предполагает, что за кодом стоит «разумный замысел» и что будет использоваться некоторое количество различных типов объектов.

Чтобы использовать это для оптимизации байтового кода, V8 использует концепцию, называемую встроенный кэш. Каждый раз, когда объект создается с помощью функции-конструктора, для него создается «форма объекта» (также известная как «скрытый класс»), и эта форма обновляется в соответствии с различными добавлениями свойств и изменениями, которые происходят с объектом. Когда свойство загружается из объекта, его форма сохраняется в кэше. Таким образом, для каждой последующей загрузки свойства из этой переменной, если его форма соответствует форме, сохраненной в кеше, механизм может использовать сохраненную информацию для правильного доступа к свойству напрямую, без необходимости вычислять, как это сделать снова. Кэш также может сохранять несколько вариантов «формы объекта», если функция вызывается с разными типами объектов.

Информация, сохраненная во встроенном кеше, также используется позже в процессе оптимизирующим компилятором.

Чтобы увидеть байт-код, сгенерированный из вашего кода, запустите:

node --print-bytecode --print-bytecode-filter="<my_func_name>" <my_file>

Помните, что если вы не запустите функцию, которую хотите разобрать, вы не увидите никакого байт-кода, потому что он пройдет только предварительный анализатор!

Для нашего примера кода мы получаем:

  1. Значение в регистре a0 (переменная, переданная в функцию) загружается в аккумулятор.
  2. Значение в аккумуляторе умножается на небольшое целое число [5] (дополнительное [0] используется для информации о типе).
  3. Возвращается значение в аккумуляторе.

Для более глубокого понимания байт-кода V8 вы можете прочитать этот пост в блоге или просмотреть эту серию на YouTube.

Третий шаг — преобразование байт-кода в оптимизированный машинный код

Как только байт-код функции запускается определенное количество раз с одним и тем же типом входных данных, движок помечает его как «горячую» функцию и отправляет его компилятору, оптимизирующему турбовентилятор, который преобразует байт код в оптимизированный машинный код. Поскольку оптимизация зависит от полученной обратной связи о типе, перед запуском оптимизированного кода важно убедиться, что полученный тип соответствует ожидаемому. Если в какой-то момент был получен неверный тип, движок «деоптимизирует» код — перестает использовать машинный код и возвращается к интерпретируемому коду. Однако эту же функцию можно снова оптимизировать в будущем.

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

Дополнительные ресурсы

Университет Хром 2018 | Парсинг JS | Обзор движков JS | Скрытые классы | AST и визуализатор токенов

Дополнительные материалы на PlainEnglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter, LinkedIn, YouTube и Discord .

Заинтересованы в масштабировании запуска вашего программного обеспечения? Ознакомьтесь с разделом Схема.