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

Первый пост из серии был посвящен обзору движка, среды выполнения и стека вызовов. Этот второй пост будет посвящен внутренним частям движка Google V8 JavaScript. Мы также дадим несколько быстрых советов о том, как написать лучший код JavaScript - передовые методы, которым следует наша команда разработчиков в SessionStack при создании продукта.

Обзор

Механизм JavaScript - это программа или интерпретатор, выполняющий код JavaScript. Механизм JavaScript может быть реализован как стандартный интерпретатор или оперативный компилятор, который компилирует JavaScript в байт-код в той или иной форме.

Это список популярных проектов, реализующих движок JavaScript:

  • V8 - открытый код, разработанный Google, написанный на C ++
  • Rhin o - управляется Mozilla Foundation, открытый исходный код, полностью разработан на Java.
  • SpiderMonkey - первый движок JavaScript, который в те времена работал с Netscape Navigator, а сегодня работает с Firefox.
  • JavaScriptCore - открытый исходный код, продается как Nitro и разработан Apple для Safari.
  • KJS - движок KDE, первоначально разработанный Харри Портеном для веб-браузера Konqueror проекта KDE.
  • Чакра (JScript9) - Internet Explorer
  • Чакра (JavaScript) - Microsoft Edge
  • Nashorn, открытый исходный код как часть OpenJDK, написанный Oracle Java Languages ​​and Tool Group
  • JerryScript - легкий движок для Интернета вещей.

Зачем был создан двигатель V8?

Двигатель V8, созданный Google, имеет открытый исходный код и написан на C ++. Этот движок используется внутри Google Chrome. Однако, в отличие от остальных движков, V8 также используется для популярной среды выполнения Node.js.

V8 был впервые разработан для повышения производительности выполнения JavaScript в веб-браузерах. Чтобы получить скорость, V8 переводит код JavaScript в более эффективный машинный код вместо использования интерпретатора. Он компилирует код JavaScript в машинный код при исполнении, реализуя JIT-компилятор (Just-In-Time), как это делают многие современные движки JavaScript, такие как SpiderMonkey или Rhino (Mozilla). Основное отличие здесь в том, что V8 не создает байт-код или какой-либо промежуточный код.

V8 имел два компилятора

До выхода версии 5.9 V8 (выпущенной ранее в этом году) движок использовал два компилятора:

  • full-codegen - простой и очень быстрый компилятор, производящий простой и относительно медленный машинный код.
  • Crankshaft - более сложный оптимизирующий компилятор (Just-In-Time), производящий высокооптимизированный код.

V8 Engine также использует несколько внутренних потоков:

  • Основной поток делает то, что вы ожидаете: извлекает ваш код, компилирует его и затем выполняет.
  • Также существует отдельный поток для компиляции, так что основной поток может продолжать выполнение, пока первый оптимизирует код.
  • Поток Profiler, который сообщит среде выполнения, на какие методы мы тратим много времени, чтобы Crankshaft мог их оптимизировать.
  • Несколько потоков для обработки сборщика мусора

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

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

Затем оптимизация коленчатого вала начинается в другом потоке. Он переводит абстрактное синтаксическое дерево JavaScript в высокоуровневое статическое представление с одним назначением (SSA) под названием Hydrogen и пытается оптимизировать этот граф Hydrogen. Большинство оптимизаций выполняется на этом уровне.

Встраивание

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

Скрытый класс

JavaScript - это язык, основанный на прототипах: здесь нет классов, а объекты создаются с помощью процесса клонирования. JavaScript также является динамическим языком программирования, что означает, что свойства могут быть легко добавлены или удалены из объекта после его создания.

Большинство интерпретаторов JavaScript используют структуры, подобные словарю (на основе хэш-функции), для хранения местоположения значений свойств объекта в памяти. Эта структура делает получение значения свойства в JavaScript более затратным в вычислительном отношении, чем в нединамическом языке программирования, таком как Java или C #. В Java все свойства объекта определяются фиксированным макетом объекта перед компиляцией и не могут быть динамически добавлены или удалены во время выполнения (ну, C # имеет динамический тип, что является другой темой). В результате значения свойств (или указатели на эти свойства) могут храниться в памяти как непрерывный буфер с фиксированным смещением между ними. Длину смещения можно легко определить на основе типа свойства, тогда как это невозможно в JavaScript, где тип свойства может изменяться во время выполнения.

Поскольку использование словарей для поиска расположения свойств объекта в памяти очень неэффективно, V8 вместо этого использует другой метод: скрытые классы. Скрытые классы работают аналогично фиксированным макетам (классам) объектов, используемым в таких языках, как Java, за исключением того, что они создаются во время выполнения. Теперь посмотрим, как они выглядят на самом деле:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);

Как только произойдет вызов «new Point (1, 2)», V8 создаст скрытый класс с именем «C0».

Для Point еще не определены свойства, поэтому «C0» пуст.

После выполнения первого оператора this.x = x (внутри функции Point) V8 создаст второй скрытый класс с именем C1, основанный на C0. C1 описывает место в памяти (относительно указателя объекта), где можно найти свойство x. В этом случае x сохраняется со значением offset 0, что означает, что при просмотре точечного объекта в памяти как непрерывного буфера первое смещение будет соответствовать свойству x. V8 также обновит C0 с помощью перехода класса, в котором указано, что если свойство x добавлено к точечному объекту, скрытый класс должен переключиться с C0 на C1. Скрытый класс для точечного объекта ниже теперь C1.

Этот процесс повторяется при выполнении оператора «this.y = y» (опять же, внутри функции Point, после оператора «this.x = x»).

Создается новый скрытый класс с именем «C2», к «C1» добавляется переход класса, указывающий, что если свойство «y» добавлено к объекту Point (который уже содержит свойство «x»), то скрытый класс должен измениться на «C2», и скрытый класс точечного объекта обновляется до «C2».

Скрытые переходы между классами зависят от порядка, в котором свойства добавляются к объекту. Взгляните на фрагмент кода ниже:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;

Теперь вы можете предположить, что и для p1, и для p2 будут использоваться одни и те же скрытые классы и переходы. Ну не совсем. Для «p1» сначала будет добавлено свойство «a», а затем свойство «b». Однако для «p2» сначала назначается «b», а затем «a». Таким образом, «p1» и «p2» имеют разные скрытые классы в результате разных путей перехода. В таких случаях гораздо лучше инициализировать динамические свойства в том же порядке, чтобы скрытые классы можно было использовать повторно.

Встроенное кеширование

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

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

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

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

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

Компиляция в машинный код

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

В конце концов, Lithium компилируется в машинный код. Затем происходит что-то еще, называемое OSR: замена в стеке. До того, как мы начали компилировать и оптимизировать заведомо долго работающий метод, мы, вероятно, уже использовали его. V8 не забудет то, что он просто медленно выполнил, чтобы снова начать с оптимизированной версии. Вместо этого он преобразует весь имеющийся у нас контекст (стек, регистры), чтобы мы могли переключиться на оптимизированную версию в середине выполнения. Это очень сложная задача, учитывая, что, помимо других оптимизаций, V8 изначально встроил код. V8 - не единственный двигатель, способный на это.

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

Вывоз мусора

Для сборки мусора V8 использует традиционный подход, основанный на разметке и очистке поколений, для очистки старого поколения. Фаза маркировки должна остановить выполнение JavaScript. Чтобы контролировать затраты на сборку мусора и сделать выполнение более стабильным, V8 использует инкрементную маркировку: вместо обхода всей кучи, попытки пометить все возможные объекты, он проходит только часть кучи, а затем возобновляет нормальное выполнение. Следующая остановка сборщика мусора будет продолжена с того места, где остановился предыдущий обход кучи. Это позволяет делать очень короткие паузы во время нормального выполнения. Как упоминалось ранее, фаза развертки обрабатывается отдельными потоками.

Зажигание и турбовентилятор

С выпуском V8 5.9 ранее в 2017 году был представлен новый конвейер выполнения. Этот новый конвейер обеспечивает еще большее повышение производительности и значительную экономию памяти в реальных приложениях JavaScript.

Новый конвейер выполнения построен поверх Ignition, интерпретатора V8 и TurboFan, новейшего оптимизирующего компилятора V8.

Вы можете проверить сообщение в блоге команды V8 по теме здесь.

С момента выхода версии 5.9 V8, полное кодогенерация и Crankshaft (технологии, которые обслуживали V8 с 2010 года) больше не использовались V8 для выполнения JavaScript, поскольку команда V8 изо всех сил пыталась идти в ногу с новыми функциями языка JavaScript и оптимизация, необходимая для этих функций.

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

Эти улучшения - только начало. Новый конвейер Ignition и TurboFan открывает путь для дальнейших оптимизаций, которые повысят производительность JavaScript и сократят влияние V8 как на Chrome, так и на Node.js в ближайшие годы.

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

Как написать оптимизированный JavaScript

  1. Порядок свойств объекта: всегда создавайте экземпляры свойств объекта в одном и том же порядке, чтобы можно было совместно использовать скрытые классы и впоследствии оптимизированный код.
  2. Динамические свойства: добавление свойств к объекту после создания экземпляра вызовет изменение скрытого класса и замедлит работу любых методов, которые были оптимизированы для предыдущего скрытого класса. Вместо этого назначьте все свойства объекта в его конструкторе.
  3. Методы: код, который многократно выполняет один и тот же метод, будет работать быстрее, чем код, который выполняет множество разных методов только один раз (из-за встроенного кэширования).
  4. Массивы: избегайте разреженных массивов, в которых ключи не являются инкрементными числами. Редкие массивы, внутри которых не все элементы, представляют собой хеш-таблицу. Доступ к элементам в таких массивах дороже. Также старайтесь избегать предварительного выделения больших массивов. Лучше расти по ходу дела. Наконец, не удаляйте элементы в массивах. Это делает ключи редкими.
  5. Значения с тегами: V8 представляет объекты и числа с 32 битами. Он использует бит, чтобы узнать, является ли он объектом (flag = 1) или целым числом (flag = 0), называемым SMI (SMall Integer) из-за его 31 бита. Затем, если числовое значение больше 31 бита, V8 поместит число в коробку, превратив его в двойное и создав новый объект, чтобы поместить число внутрь. По возможности старайтесь использовать 31-битные числа со знаком, чтобы избежать дорогостоящей операции упаковки в объект JS.

Мы в SessionStack стараемся следовать этим передовым методам при написании высокооптимизированного кода JavaScript. Причина в том, что как только вы интегрируете SessionStack в свое производственное веб-приложение, оно начинает записывать все: все изменения DOM, взаимодействия с пользователем, исключения JavaScript, трассировки стека, неудачные сетевые запросы и сообщения отладки.
С помощью SessionStack вы можете воспроизводить проблемы в своих веб-приложениях в виде видео и видеть все, что произошло с вашим пользователем. И все это должно происходить без ущерба для производительности вашего веб-приложения.
Существует бесплатный план, который позволяет вам начать работу бесплатно.

Ресурсы