Сегодня мы публикуем вторую часть перевода материала, посвящённого внутренним механизмам V8 и расследованию проблемы с производительностью React.
Устаревание и миграция форм объектов
Что если поле изначально содержало Smi
-значение, а потом ситуация изменилось и в нём понадобилось хранить значение, для которого представление Smi
не подходит? Например — как в следующем примере, когда два объекта представлены с использованием одной и той же формы объекта, в которой x
изначально хранится в виде Smi
:
const a = { x: 1 };
const b = { x: 2 };
// Сейчас `x` в объектах представлено в виде поля `Smi`
b.x = 0.2;
// Теперь `b.x` представлено в виде поля `Double`
y = a.x;
В начале примера у нас имеются два объекта, для представления которых используется одна и та же форма объекта, в которой для хранения x
используется формат Smi
.
Для представления объектов используется одна и та же форма
Когда свойство b.x
меняется и для его представления приходится использовать формат Double
, V8 выделяет в памяти место под новую форму объекта, в которой x
назначается представление Double
, и которая указывает на пустую форму. V8, кроме того, создаёт сущность MutableHeapNumber
, которая используется для хранения значения 0.2 свойства x
. Затем мы обновляем объект b
так, чтобы он ссылался бы на эту новую форму и изменяем слот в объекте так, чтобы он ссылался бы на ранее созданную сущность MutableHeapNumber
по смещению 0. И наконец, мы помечаем старую форму объекта как устаревшую и отключаем её от дерева переходов. Делается это путём создания нового перехода для 'x'
из пустой формы в ту, которую мы только что создали.
Последствия назначения свойству объекта нового значения
В этот момент мы не можем полностью удалить старую форму, так как она всё ещё используется объектом a
. К тому же, весьма затратным будет обход всей памяти в поиске всех объектов, ссылающихся на старую форму, и немедленное обновление состояния этих объектов. Вместо этого V8 использует тут «ленивый» подход. А именно, все операции по чтению или записи свойств объекта a
сначала переводятся на использование новой формы. Идея, заложенная в этом действии, заключается в том, чтобы в итоге сделать устаревшую форму объекта недостижимой. Это приведёт к тому, что с ней разберётся сборщик мусора.
Память, занимаемую устаревшей формой, освободит сборщик мусора
Сложнее обстоят дела в ситуациях, когда поле, меняющее представление, не является последним в цепочке:
const o = {
x: 1,
y: 2,
z: 3,
};
o.y = 0.1;
В этом случае V8 необходимо найти так называемую форму разделения (split shape). Это — последняя форма в цепочке, находящаяся до формы, в которой появляется соответствующее свойство. Здесь мы меняем y
, то есть — нам надо найти последнюю форму, в которой не было y
. В нашем примере это — форма, в которой появляется x
.
Поиск последней формы, в которой не было изменённого значения
Здесь мы, начиная с этой формы, создаём новую цепочку переходов для y
, которая воспроизводит все предыдущие переходы. Только теперь свойство 'y'
будет представлено в виде Double
. Теперь мы используем эту новую цепочку переходов для y
, помечая как устаревшее старое поддерево. На последнем шаге мы осуществляем миграцию экземпляра объекта o
на новую форму, используя теперь для хранения значения y
сущность MutableHeapNumber
. При таком подходе новый объект не будет использовать фрагменты старого дерева переходов и, после того, как все ссылки на старую форму исчезнут, исчезнет и устаревшая часть дерева.
Расширяемость и целостность переходов
Команда Object.preventExtensions()
позволяет полностью запретить добавление в объект новых свойств. Если обработать объект этой командой и попытаться добавить в него новое свойство — будет выдано исключение. (Правда, если код выполняется не в строгом режиме, то исключение выдано не будет, однако попытка добавления свойства просто не приведёт ни к каким последствиям). Вот пример:
const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
Метод Object.seal()
действует на объекты так же, как и Object.preventExtensions()
, но он, кроме того, помечает все свойства как не поддающиеся настройке. Это означает, что их нельзя удалить, нельзя и изменить их свойства, касающиеся возможностей их перечисления, настройки или перезаписи.
const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
Метод Object.freeze()
выполняет те же действия, что и Object.seal()
, но его использование, кроме того, ведёт к тому, что значения существующих свойств нельзя менять. Они помечаются как свойства, в которые нельзя записывать новые значения.
const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
object.x = 3;
// TypeError: Cannot assign to read-only property x
Рассмотрим конкретный пример. У нас имеются два объекта, каждый из которых имеет единственное значение x
. Затем мы запрещаем расширение второго объекта:
const a = { x: 1 };
const b = { x: 2 };
Object.preventExtensions(b);
Обработка этого кода начинается с действий, которые нам уже известны. А именно, производится переход от пустой формы объекта к новой форме, которая содержит свойство 'x'
(представленное в виде сущности Smi
). Когда мы запрещаем расширение объекта b
— это приводит к выполнению особого перехода к новой форме, которая отмечена как нерасширяемая. Этот особый переход не приводит к появлению некоего нового свойства. Это, на самом деле, просто маркер.
Результат обработки объекта с помощью метода Object.preventExtensions()
Обратите внимание на то, что мы не можем просто поменять существующую форму с имеющимся в ней значением x
, так как она нужна другому объекту, а именно — объекту a
, который всё ещё поддаётся расширению.
Проблема с производительностью React
Теперь давайте соберём всё то, о чём мы говорили, и воспользуемся полученными знаниями для понимания сущности недавней проблемы с производительностью React. Когда команда React профилировала реальные приложения, она заметила странную деградацию производительности V8, которая действовала на ядро React. Вот упрощённое воспроизведение проблемного участка кода:
const o = { x: 1, y: 2 };
Object.preventExtensions(o);
o.y = 0.2;
У нас имеется объект с двумя полями, представленными в виде сущностей Smi
. Мы предотвращаем дальнейшее расширение объекта, после чего выполняем действие, которое приводит к тому, что второе поле приходится представлять в формате Double
.
Мы уже выяснили, что запрет расширения объекта приводит к примерно следующей ситуации.
Последствия запрета расширения объекта
Для представления обеих свойств объекта используются сущности Smi
, а последний переход нужен для того, чтобы пометить форму объекта как нерасширяемую.
Теперь нам нужно изменить способ представления свойства y
на Double
. Это означает, что нам требуется приступить к поиску формы разделения. В данном случае это форма, в которой появляется свойство x
. Но теперь V8 оказывается в замешательстве. Дело в том, что форма разделения была расширяемой, а текущая форма была помечена как нерасширяемая. V8 не знает о том, как в подобной ситуации воспроизвести процесс переходов. В результате движок попросту отказывается от попыток во всём этом разобраться. Вместо этого он просто создаёт отдельную форму, которая не связана с текущим деревом формы и не используется совместно с другими объектами. Это — нечто вроде «осиротевшей» формы объекта.
«Осиротевшая» форма
Несложно догадаться, что это, если подобное происходит с множеством объектов, очень плохо. Дело в том, что это делает бесполезной всю систему форм объектов V8.
При проявлении недавней проблемы с React происходило следующее. Каждый объект класса FiberNode
имел поля, которые предназначались для хранения отметок времени при включенном профилировании.
class FiberNode {
constructor() {
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}
const node1 = new FiberNode();
const node2 = new FiberNode();
Эти поля (например — actualStartTime
) инициализировались значениями 0 или -1. Это приводило к тому, что для внутреннего представления их значений использовались сущности Smi
. Но позже в них сохранялись реальные отметки времени в формате чисел с плавающей точкой, возвращаемые методом performance.now(). Это приводило к тому, что эти значения уже нельзя было представить в виде Smi
. Для представления этих полей теперь требовались сущности Double
. Вдобавок ко всему этому в React ещё и предотвращалось расширение экземпляров класса FiberNode
.
Изначально наш упрощённый пример можно было бы представить в следующем виде.
Начальное состояние системы
Тут имеются два экземпляра класса, совместно использующих одно и то же дерево переходов формы объектов. Собственно говоря, это — то, на что рассчитана система форм объектов в V8. Но затем, когда в объекте сохраняются реальные отметки времени, V8 не может понять то, как ему найти форму разделения.
V8 оказывается в замешательстве
V8 назначает новую «осиротевшую» форму объекту node1
. То же самое немного позже происходит и с объектом node2
. В результате у нас теперь имеются две «осиротевшие» формы, каждая из которых используется только одним объектом. Во множестве реальных React-приложений количество подобных объектов куда больше, чем два. Это могут быть десятки или даже тысячи объектов класса FiberNode
. Несложно понять, что подобная ситуация не особенно хорошо сказывается на производительности V8.
К счастью мы исправили эту проблему в V8 v7.4, и мы исследуем возможность того, чтобы сделать операцию изменения представления полей объектов менее ресурсозатратной. Это позволит нам решить оставшиеся проблемы с производительностью, возникающие в подобных ситуациях. V8, благодаря исправлению, теперь правильно ведёт себя в вышеописанной проблемной ситуации.
Начальное состояние системы
Вот как это выглядит. Два экземпляра класса FiberNode
ссылаются на нерасширяемую форму. При этом 'actualStartTime'
представлено в виде Smi
-поля. Когда выполняется первая операция присваивания значения свойству node1.actualStartTime
— создаётся новая цепочка переходов, а предыдущая цепочка помечается как устаревшая.
Результаты присвоения нового значения свойству node1.actualStartTime
Обратите внимание на то, что в новой цепочке теперь правильно воспроизводится переход к нерасширяемой форме. Вот в какое состояние попадает система после изменения значения node2.actualStartTime
.
Результаты присвоения нового значения свойству node2.actualStartTime
После того, как новое значение присвоено свойству node2.actualStartTime
, оба объекта ссылаются на новую форму, а устаревшая часть дерева переходов может быть уничтожена сборщиком мусора.
Обратите внимание на то, что операции по пометке форм объектов в виде устаревших и их миграция может выглядеть как нечто сложное. На самом деле — так оно и есть. Мы подозреваем, что на реальных веб-сайтах это приносит больше вреда (в плане производительности, использования памяти, сложности), чем пользы. Особенно — после того, как, в случае со сжатием указателей, мы больше не можем использовать этот подход для хранения Double
-полей в виде значений, встроенных в объекты. В результате мы надеемся полностью отказаться от механизма устаревания форм объектов V8 и сделать сам этот механизм устаревшим.
Надо отметить, что команда React решила рассматриваемую проблему своими силами, сделав так, чтобы поля в объектах класса FiberNodes
изначально были бы представлены значениями Double:
class FiberNode {
constructor() {
// Принуждаем систему использовать представление `Double` с самого начала.
this.actualStartTime = Number.NaN;
// После этого можно инициализировать значение свойства так, как нужно:
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}
const node1 = new FiberNode();
const node2 = new FiberNode();
Здесь вместо Number.NaN
может быть использовано любое значение с плавающей точкой, не укладывающееся в диапазон Smi
. Среди таких значений — 0.000001, Number.MIN_VALUE
, -0 и Infinity
.
Стоит отметить то, что описываемая проблема в React была специфичной для V8, и то, что, создавая некий код, разработчикам не нужно стремиться к оптимизации его в расчёте на конкретную версию некоего JavaScript-движка. Однако полезно иметь возможность что-то исправить, оптимизируя код, в том случае, если причины неких ошибок коренятся в особенностях движка.
Стоит помнить о том, что в недрах JS-движков происходит много всяких удивительных вещей. JS-разработчик может помочь всем этим механизмам, по возможности не присваивая одним и тем же переменным значения разных типов. Например, не стоит инициализировать числовые поля значением null
, так как это сведёт на нет все преимущества от наблюдения за представлением поля и улучшит читаемость кода:
// Не делайте этого!
class Point {
x = null;
y = null;
}
const p = new Point();
p.x = 0.1;
p.y = 402;
Другими словами — пишите читабельный код, а производительность придёт сама!
Итоги
В этом материале мы рассмотрели следующие важные вопросы:
- JavaScript различает «примитивные» и «объектные» значения, а результатам
typeof
нельзя доверять. - Даже значения, имеющие один и тот же JavaScript-тип, могут быть представлены разными способами в недрах движка.
- V8 пытается найти оптимальный способ представления для каждого свойства объекта, используемого в JS-программах.
- В определённых ситуациях V8 выполняет операции по пометке форм объектов в виде устаревших и выполняет миграцию форм. В том числе — реализует переходы, связанные с запретом расширения объектов.
Основываясь на вышесказанном, мы можем дать некоторые практические советы по JavaScript-программированию, которые могут помочь в деле повышения производительности кода:
- Всегда инициализируйте свои объекты одним и тем же способом. Это способствует эффективной работе с формами объектов.
- Ответственно подходите к выбору начальных значений для полей объектов. Это поможет JavaScript-движкам в выборе способа внутреннего представления этих значений.
Уважаемые читатели! Оптимизировали ли вы когда-нибудь свой код в расчёте на внутренние особенности неких JavaScript-движков?
Автор: ru_vds