Добрый день, друзья! Курс «Безопасность информационных систем» запущен, в связи с этим делимся с вами завершающей частью статьи «Основы движков JavaScript: оптимизация прототипов», первую часть которой можно прочитать тут.

Также напоминаем о том, что нынешняя публикация является продолжением вот этих двух статей: «Основы движков JavaScript: общие формы и Inline кэширование. Часть 1», «Основы движков JavaScript: общие формы и Inline кэширование. Часть 2».



Классы и прототипное программирование

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

class Bar {
	constructor(x) {
		this.x = x;
	}
	getX() {
		return this.x;
	}
}

Несмотря на то, что это кажется относительно новой концепцией для JavaScript, это просто «синтаксический сахар» для прототипного программирования, которое всегда использовалось в JavaScript:

function Bar(x) {
	this.x = x;
}

Bar.prototype.getX = function getX() {
	return this.x;
};

Здесь мы присваиваем свойство getX к объекту Bar.prototype. Это будет работать также, как и с любым другим объектом, поскольку прототипы в JavaScript– это такие же объекты. В прототипных языках программирования, таких как JavaScript, доступ к методам осуществляется через прототипы, тогда как поля хранятся в конкретных экземплярах.

Давайте рассмотрим подробнее, что происходит, когда мы создаем новый экземпляр Bar, который назовем foo.

const foo = new Bar(true);

Экземпляр, созданный с помощью этого кода, имеет форму с единственным свойством ‘x’. Прототип foo – это Bar.prototype, который принадлежит классу Bar.



Этот Bar.prototype имеет форму самого себя, содержащую единственное свойство ‘getX’, чье значение определяется функцией ‘getX’, которая при вызове возвращает this.x. Прототип Bar.prototype — это Object.prototype, который является частью языка JavaScript. Object.prototype – это корень дерева прототипов, тогда как прототип его имеет значение null.



Когда вы создаете новый экземпляр того же самого класса, оба экземпляра имеют одну форму, как мы уже поняли ранее. Оба экземпляра будут указывать на один и тот же объект Bar.prototype.

Доступ к свойствам прототипа

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

class Bar {
	constructor(x) { this.x = x; }
	getX() { return this.x; }
}

const foo = new Bar(true);
const x = foo.getX();
//        ^^^^^^^^^^

Вы можете рассматривать любой вызов метода, как два отдельных шага:

const x = foo.getX();

// is actually two steps:

const $getX = foo.getX;
const x = $getX.call(foo);

Первый шаг – это загрузка метода, который фактически является свойством прототипа (чье значение оказывается функцией). Второй шаг – это вызов функции с экземпляром, к примеру, значение this. Давайте поподробнее рассмотрим первый шаг, на котором происходит загрузка метода getX из экземпляра foo.



Движок запускает экземпляр foo и понимает, что у формы foo нет никакого свойства ‘getX’, поэтому ему приходится проходить цепь прототипов, чтобы найти его. Мы добираемся до Bar.prototype, смотрим на форму прототипа, видим, что в ней есть свойство ‘getX’ на нулевом смещении. Мы ищем значение по этому смещению в Bar.prototype и находим JSFunction getX, которую мы и искали.

Гибкость JavaScript позволяет звеньям цепи прототипов изменяться, например:

const foo = new Bar(true);
foo.getX();
// > true

Object.setPrototypeOf(foo, null);
foo.getX();
// > Uncaught TypeError: foo.getX is not a function

В этом примере мы вызываем
foo.getX()
дважды, но каждый раз она имеет совершенно разные значения и результаты. Именно поэтому, несмотря на то что прототипы это просто объекты в JavaScript, ускорение доступа к свойствам прототипа – это даже более важная задача для движков JavaScript, чем ускорение собственного доступа к свойствам к регулярным объектам.

В повседневной практике загрузка свойств прототипа достаточно частая операция: это происходит каждый раз, когда вы вызываете метод!

class Bar {
	constructor(x) { this.x = x; }
	getX() { return this.x; }
}

const foo = new Bar(true);
const x = foo.getX();
//        ^^^^^^^^^^

Ранее мы говорили о том, как движки оптимизируют загрузку регулярных (regular), собственных свойств за счет использования форм и Inline кэшей. Каким образом можно оптимизировать загрузку свойств прототипов для объектов одной формы? Сверху мы видели, как происходит загрузка свойств.



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

  • Форма foo не содержит ‘getX’ и она не менялась. Это значит, что никто не изменял объект foo путем добавления или удаления свойства или изменения одного из атрибутов свойства.
  • Прототип foo это все еще изначальный Bar.prototype. Значит никто не изменял прототип foo используя Object.setPrototypeOf() или присваивая его специальному _proto_ свойству.
  • Форма Bar.prototype содержит 'getX' и не менялась. Это значит, что никто не изменял Bar.prototype путем добавления или удаления свойства или изменения одного из атрибутов свойства.

В общем случае это значит, что нужно сделать одну проверку самого экземпляра и еще две проверки для каждого прототипа вплоть до того прототипа, который содержит искомое свойство. 1+2N проверок, где N – это количество используемых прототипов, звучит не так плохо в этом случае, поскольку цепь прототипов относительно неглубокая. Однако двигателям часто приходится иметь дело с гораздо более длинными цепями прототипов, как в случае обычных DOM классов. Например:

const anchor = document.createElement('a');
// > HTMLAnchorElement

const title = anchor.getAttribute('title');

У нас есть HTMLAnchorElement и мы вызываем метод getAttribute(). Цепочка для этого простого элемента уже включает в себя 6 прототипов! Большинство интересных нам методов DOM находятся не в самом прототипе HTMLAnchorElement, а где-то выше по цепи.



Метод getAttribute() находится в Element.prototype. Это значит, что каждый раз, когда мы вызываем anchor.getAttribute(), движку JavaScript нужно:

  1. Проверить, что 'getAttribute' не является самим по себе anchor объектом;
  2. Проверить, что конечный прототип – это HTMLAnchorElement.prototype;
  3. Подтвердить отсутствие 'getAttribute' там;
  4. Проверить, что следующий прототип – это HTMLElement.prototype;
  5. Снова подтвердить отсутствие 'getAttribute' ;
  6. Проверить, что следующий прототип это Element.prototype;
  7. Проверить, что в нем присутствует 'getAttribute'.

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

Возвращаясь к более раннему примеру, в котором мы делали всего три проверки, когда запрашивали 'getX' для foo:

class Bar {
	constructor(x) { this.x = x; }
	getX() { return this.x; }
}

const foo = new Bar(true);
const $getX = foo.getX;

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



Каждая форма указывает на прототип. Это значит, что каждый раз, когда изменяется прототип foo, движок переходит к новой форме. Теперь нам нужно проверять только форму объекта, чтобы подтвердить отсутствие определенных свойств, а также защитить ссылку прототипа (guard the prototype link).

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

Ячейки валидности (Validity cells)

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



Эта ValidityCell каждый раз инвалидируется, когда кто-то изменяет связанный с ней прототип или любой другой прототип над ним. Давайте посмотрим, как это работает.
Чтобы ускорить последующие загрузки из прототипов, V8 помещает Inline кэш в место с четырьмя полями:



При разогреве inline кэша при первом запуске кода, V8 запоминает смещение, по которому свойство было найдено в прототипе, этот прототип (например, Bar.prototype), форму экземпляра (в нашем случае форма foo), а также привязывает текущую ValidityCell к прототипу, полученному из экземпляра формы (в нашем случае берется Bar.prototype).

В следующий раз, когда используется Inline кэш, движку необходимо проверить форму экземпляра и ValidityCell. Если она все еще валидна, движок напрямую использует смещение на прототипе, пропуская лишние шаги поиска.



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

Вернемся к примеру с DOM элементом. Каждое изменение в Object.prototype не просто инвалидирует Inline кэши для Object.prototype, но и для любого прототипа в цепи под ним, включая EventTarget.prototype, Node.prototype, Element.prototype и т. д. до самого HTMLAnchorElement.prototype.



Фактически, модификация Object.prototype в течение того времени, пока выполняется код – это ужасная потеря производительности. Не делайте этого!

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

class Bar { /* … */ }

function loadX(bar) {
	return bar.getX(); // IC for 'getX' on `Bar` instances.
}

loadX(new Bar(true));
loadX(new Bar(false));
// IC in `loadX` now links the `ValidityCell` for
// `Bar.prototype`.

Object.prototype.newMethod = y => y;
// The `ValidityCell` in the `loadX` IC is invalid
// now, because `Object.prototype` changed.

Inline кэш в loadX сейчас указывает на ValidityCell для Bar.prototype. Если затем вы измените (mutate) Object.prototype, который является корнем всех прототипов в JavaScript, ValidityCell становится невалидной и существующие Inline кэши не будут использоваться в следующий раз, что приводит к ухудшению производительности.

Изменение Object.prototype – всегда плохая идея, поскольку это инвалидирует любые Inline кэши для загруженных прототипов на момент изменения. Вот пример того, как НЕ НАДО делать:

Object.prototype.foo = function() { /* … */ };

// Run critical code:
someObject.foo();
// End of critical code.

delete Object.prototype.foo;

Мы расширяем Object.prototype, что инвалидирует все Inline кэши прототипов, загруженные движком к этому моменту. Затем мы запускам некоторый код, который использует описанный нами метод. Движку придется начинать с самого начала и настроить Inline кэши для любого доступа к свойству прототипа. И затем, наконец-то «прибираем за собой» и удаляем метод прототипа, который мы добавили ранее.

Думаете, что уборка — это хорошая идея, не так ли? Что ж, в таком случае она еще больше ухудшит сложившуюся ситуацию! Удаление свойств изменяет Object.prototype, таким образом все Inline кэши снова инвалидируются, а движку приходится начинать работу с самого начала снова.

Подведем итог. Несмотря на то, что прототипы – это всего лишь объекты, они специальным образом обрабатываются движками JavaScript, чтобы оптимизировать производительность поиска методов по прототипам. Оставьте в покое прототипы! Или же если вам действительно нужно иметь с ними дело, делайте это до выполнения кода, таким образом вы как минимум не инвалидируете все попытки оптимизировать ваш код, в процессе его выполнения!

Обобщим

Мы узнали, как JavaScript хранит объекты и классы, и как формы, Inline кэши и ячейки валидности помогают оптимизировать операции с прототипами. Основываясь на этих знаниях, мы поняли, как с практической точки зрения улучшить производительность: не трогайте прототипы! (или если вам действительно это нужно, делайте это перед исполнением кода).

< Первая часть

Была ли эта серия публикаций полезной для вас? Пишите в комментарии.

Комментарии (1)


  1. b360124
    25.04.2019 00:46
    +1

    Спасибо, за перевод. Очень годный метариал, жду продолжения )