Основы JavaScript: зачем нам знать, как работают движки

NOP
NOP::Nuances of Programming
7 min readAug 1, 2018

Перевод статьи Rainer Hahnekamp: JavaScript essentials: why you should know how the engine works

Фото Moto “Club4AG” Miwa на Flickr

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

Ниже вы увидите одностраничную функцию, которая возвращает свойство lastName переданного аргумента. Причем при добавлении одного свойства на объект производительность резко падает аж на 700%!

Как вы увидите далее, такое поведение вызвано отсутствием статичных типов в JavaScript. Когда-то это считалось главным преимуществом JavaScript перед C# и Java, но на деле оказалось той еще «сделкой с дьяволом».

Торможение на полном ходу

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

Супер!

Пусть другие делают все самое сложное. Зачем нам вникать в принципы работы движков?

В коде ниже у нас есть пять объектов, которые хранят имена и фамилии персонажей из «Звездных войн». Функция getName возвращает значение lastname. Мы измеряем суммарное время, за которое эта функция выполняется 1 миллиард раз:

(() => { 
const han = {firstname: "Han", lastname: "Solo"};
const luke = {firstname: "Luke", lastname: "Skywalker"};
const leia = {firstname: "Leia", lastname: "Organa"};
const obi = {firstname: "Obi", lastname: "Wan"};
const yoda = {firstname: "", lastname: "Yoda"};
const people = [
han, luke, leia, obi,
yoda, luke, leia, obi
];
const getName = (person) => person.lastname;
console.time("engine");
for(var i = 0; i < 1000 * 1000 * 1000; i++) {
getName(people[i & 7]);
}
console.timeEnd("engine");
})();

В Intel i7 4510U время выполнения данной операции — около 1,2 сек. Вполне себе прилично. А сейчас добавим еще по одному свойству на объект и заново выполним код.

(() => {
const han = {
firstname: "Han", lastname: "Solo",
spacecraft: "Falcon"};
const luke = {
firstname: "Luke", lastname: "Skywalker",
job: "Jedi"};
const leia = {
firstname: "Leia", lastname: "Organa",
gender: "female"};
const obi = {
firstname: "Obi", lastname: "Wan",
retired: true};
const yoda = {lastname: "Yoda"};
const people = [
han, luke, leia, obi,
yoda, luke, leia, obi];
const getName = (person) => person.lastname;
console.time("engine");
for(var i = 0; i < 1000 * 1000 * 1000; i++) {
getName(people[i & 7]);
}
console.timeEnd("engine");
})();

В этот раз время увеличилось до 8,5 сек., то есть код выполняется почти в 7 раз медленнее, чем раньше. Мы как будто тормозим на полном ходу. Как такое вообще возможно?

А теперь давайте изучим сами движки.

Совместными усилиями: интерпретатор и компилятор

Движок — это некая структура, которая читает и выполняет исходный код. Каждый крупный производитель браузеров создает собственный движок. В Mozilla Firefox — это Spidermonkey, в Microsoft Edge есть Chakra/ChakraCore, а для Apple Safari придумали JavaScriptCore. Google Chrome пользуется V8. Кстати, Node.js работает на том же движке.

Выпуск V8 в 2008 году стал ключевой вехой в истории движков. V8 заменил относительно медленную интерпретацию JavaScript браузером.

Причина столь мощного сдвига кроется в совместном использовании компилятора и интерпретатора. Сегодня эту технологию используют уже все четыре движка.

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

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

Основное назначение современных движков — это некое сочетание несочетаемого:

· Быстрый запуск приложения интерпретатором.

· Быстрое выполнение компилятора.

Всегда двое там есть — интерпретатор и компилятор.
Современные движки пользуются интерпретатором и компилятором. Источник: imgflip

Начинается все с интерпретатора. Параллельно движок размечает наиболее используемые части кода как «hot path» и передает их компилятору с контекстной информацией, собранной в процессе выполнения. Благодаря этому компилятор адаптирует и оптимизирует код под текущий контекст.

Такое подведение компилятора мы называем «Just in Time» («на лету») или просто JIT. При хорошей работе движка JavaScript может показать себя лучше C++. К тому же большая часть работы движка ведется именно по «контекстной оптимизации».

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

Статические типы в среде выполнения: встроенное кэширование

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

С помощью встроенного кэширования каждый объект присваивается некоему «типу», который создается движком в среде выполнения. Такие типы — не являющиеся частью стандарта ECMAScript — V8 называет «скрытыми классами» или «формами объекта». Два объекта одной формы должны содержать одни и те же свойства в одинаковом порядке. Поэтому классы объектов {firstname: “Han”, lastname: “Solo”} и {lastname: “Solo”, firstname: “Han”} будут разными.

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

Для чего нужно встроенное кэширование? Оно убирает операцию поиска. Разумеется, производительность от этого только возрастает.

Возвращаясь к нашему примеру: при первом выполнении кода у всех объектов имелось только два свойства: firstname и lastname. Причем именно в таком порядке. В качестве внутреннего имени формы объекта возьмем p1. Когда компилятор запускает встроенное кэширование, ему кажется, что функция передает только форму объекта p1, а затем сразу же возвращает значение lastname.

Процесс встроенного кэширования (мономорфный)

При повторном запуске у нас уже 5 различных форм объекта. В каждом объекте присутствует дополнительное свойство, а в yoda полностью отсутствует firstname. Как же будет происходить взаимодействие сразу с несколькими формами объекта?

Что же выбрать: неявная типизация или множественные типы?

В функциональном программировании есть такая вещь, как неявная («утиная») типизация — это когда качественный код вызывает функции, которые обрабатывают сразу несколько типов. Вернемся к примеру: пока в передаваемом объекте имеется только одно свойство lastname, — все идет хорошо.

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

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

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

Поли- и мегаморфизм в действии

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

Полиморфное встроенное кэширование

Вот здесь показано мегаморфное встроенное кэширование нашего кода с пятью различными формами объектов:

Мегаморфное встроенное кэширование

Классы JavaScript спешат на помощь

Допустим, у нас есть целых 5 форм объектов, и мы перешли в полиморфное кэширование. Как же решить эту проблему?

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

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

(() => {
class Person {
constructor({
firstname = '',
lastname = '',
spaceship = '',
job = '',
gender = '',
retired = false
} = {}) {
Object.assign(this, {
firstname,
lastname,
spaceship,
job,
gender,
retired
});
}
}
const han = new Person({
firstname: 'Han',
lastname: 'Solo',
spaceship: 'Falcon'
});
const luke = new Person({
firstname: 'Luke',
lastname: 'Skywalker',
job: 'Jedi'
});
const leia = new Person({
firstname: 'Leia',
lastname: 'Organa',
gender: 'female'
});
const obi = new Person({
firstname: 'Obi',
lastname: 'Wan',
retired: true
});
const yoda = new Person({ lastname: 'Yoda' });
const people = [
han,
luke,
leia,
obi,
yoda,
luke,
leia,
obi
];
const getName = person => person.lastname;
console.time('engine');
for (var i = 0; i < 1000 * 1000 * 1000; i++) {
getName(people[i & 7]);
}
console.timeEnd('engine');
})();

При повторном запуске той же функции время выполнения опять взлетит до 1,2 секунды. Проблема решена!

Выводы

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

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

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

Использование классов JavaScript пойдет вам на пользу. Статичные типизированные транспилеры по типу TypeScript делают встроенное мономорфное кэширование более привлекательным.

--

--