Добрый день, друзья! Курс «Безопасность информационных систем» запущен, в связи с этим делимся с вами завершающей частью статьи «Основы движков JavaScript: оптимизация прототипов», первую часть которой можно прочитать тут.
Также напоминаем о том, что нынешняя публикация является продолжением вот этих двух статей: «Основы движков JavaScript: общие формы и Inline кэширование. Часть 1», «Основы движков JavaScript: общие формы и Inline кэширование. Часть 2».
Классы и прототипное программирование
Теперь, когда мы знаем, как получить быстрый доступ к свойствам объектов JavaScript, можно взглянуть на более сложную структуру JavaScript – классы. Вот так выглядит синтаксис класса на JavaScript:
Несмотря на то, что это кажется относительно новой концепцией для JavaScript, это просто «синтаксический сахар» для прототипного программирования, которое всегда использовалось в JavaScript:
Здесь мы присваиваем свойство
Давайте рассмотрим подробнее, что происходит, когда мы создаем новый экземпляр
Экземпляр, созданный с помощью этого кода, имеет форму с единственным свойством
Этот
Когда вы создаете новый экземпляр того же самого класса, оба экземпляра имеют одну форму, как мы уже поняли ранее. Оба экземпляра будут указывать на один и тот же объект
Доступ к свойствам прототипа
Хорошо, теперь мы знаем, что случается, когда мы определяем класс и создаем новый экземпляр. Но что произойдет, если мы вызовем метод на экземпляре, как мы поступили в следующем примере?
Вы можете рассматривать любой вызов метода, как два отдельных шага:
Первый шаг – это загрузка метода, который фактически является свойством прототипа (чье значение оказывается функцией). Второй шаг – это вызов функции с экземпляром, к примеру, значение
Движок запускает экземпляр
Гибкость JavaScript позволяет звеньям цепи прототипов изменяться, например:
В этом примере мы вызываем
В повседневной практике загрузка свойств прототипа достаточно частая операция: это происходит каждый раз, когда вы вызываете метод!
Ранее мы говорили о том, как движки оптимизируют загрузку регулярных (regular), собственных свойств за счет использования форм и Inline кэшей. Каким образом можно оптимизировать загрузку свойств прототипов для объектов одной формы? Сверху мы видели, как происходит загрузка свойств.
Для того, чтобы делать это быстро при повторных загрузках в этом конкретном случае, нужно знать следующие три вещи:
В общем случае это значит, что нужно сделать одну проверку самого экземпляра и еще две проверки для каждого прототипа вплоть до того прототипа, который содержит искомое свойство. 1+2N проверок, где N – это количество используемых прототипов, звучит не так плохо в этом случае, поскольку цепь прототипов относительно неглубокая. Однако двигателям часто приходится иметь дело с гораздо более длинными цепями прототипов, как в случае обычных DOM классов. Например:
У нас есть
Метод
Итого получается 7 проверок. Поскольку этот вид кода довольно распространен в вебе, движки применяют различные хитрости, чтобы уменьшить количество проверок, необходимых для загрузки свойств прототипов.
Возвращаясь к более раннему примеру, в котором мы делали всего три проверки, когда запрашивали
Для каждого объекта, который встречается до прототипа, содержащего нужное свойство, необходимо проверять формы на отсутствие этого свойства. Было бы неплохо, если бы мы могли уменьшить количество проверок, представив проверку прототипа, как проверку на отсутствие свойства. По сути это именно то, что движки делают с помощью простой хитрости: вместо того, чтобы хранить ссылку прототипа на сам экземпляр, движки хранят ее в форме.
Каждая форма указывает на прототип. Это значит, что каждый раз, когда изменяется прототип
С таким подходом мы можем сокращать количество требуемых проверок с 2N+1 до 1+N для ускорения доступа. Это все еще достаточно затратная операция, поскольку это все еще линейная функция от количества прототипов в цепи. Движки используют различные хитрости для еще большего уменьшения количества проверок до определенного константного значения, особенно в случае последовательного выполнения загрузки одних и тех же свойств.
Ячейки валидности (Validity cells)
V8 обрабатывает формы прототипа специально с этой целью. Каждый прототип имеет уникальную форму, которая не используется совместно с другими объектами (в частности, с другими прототипами), и каждая из этих форм прототипа имеет специальную
Эта
Чтобы ускорить последующие загрузки из прототипов, V8 помещает Inline кэш в место с четырьмя полями:
При разогреве inline кэша при первом запуске кода, V8 запоминает смещение, по которому свойство было найдено в прототипе, этот прототип (например,
В следующий раз, когда используется Inline кэш, движку необходимо проверить форму экземпляра и
При изменении прототипа выделяется новая форма, а предыдущая ячейка
Вернемся к примеру с DOM элементом. Каждое изменение в
Фактически, модификация
Давайте рассмотрим конкретный пример, чтобы понять получше как это работает. Скажем, у нас есть класс
Inline кэш в
Изменение
Мы расширяем
Думаете, что уборка — это хорошая идея, не так ли? Что ж, в таком случае она еще больше ухудшит сложившуюся ситуацию! Удаление свойств изменяет
Подведем итог. Несмотря на то, что прототипы – это всего лишь объекты, они специальным образом обрабатываются движками 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 нужно:- Проверить, что
'getAttribute'
не является самим по себеanchor
объектом; - Проверить, что конечный прототип – это
HTMLAnchorElement.prototype
; - Подтвердить отсутствие
'getAttribute'
там; - Проверить, что следующий прототип – это
HTMLElement.prototype
; - Снова подтвердить отсутствие
'getAttribute'
; - Проверить, что следующий прототип это
Element.prototype
; - Проверить, что в нем присутствует
'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 кэши и ячейки валидности помогают оптимизировать операции с прототипами. Основываясь на этих знаниях, мы поняли, как с практической точки зрения улучшить производительность: не трогайте прототипы! (или если вам действительно это нужно, делайте это перед исполнением кода).
< Первая часть
Была ли эта серия публикаций полезной для вас? Пишите в комментарии.
b360124
Спасибо, за перевод. Очень годный метариал, жду продолжения )