Оглавление
1.6 Ядро: движок V8
1.6.2 Доступ к свойствам объекта
1.6.3 Генерация Машинного кода
1.6 Ядро: движок V8
Для Вас, как для разработчика, необходимо хорошо разбираться в той или иной технологии, чтобы можно было ее применять в своем проекте. В следующих разделах книги мы углубимся во внутреннее устройство Node.js для того, чтобы изучить компоненты из которых состоит платформа и то как использовать все эти возможности для разработки приложения.
Главный и наиболее важный компонент платформы Node.js – это JavaScript V8 движок, разработанный компанией Google(если Вы заинтересовались этой темой – посетите сайт проекта V8 – https://code.google.com/p/v8/ . Движок JavaScript отвечает за интерпретацию и выполнение исходного JavaScript кода. Для JavaScript существует множество движков, многие производители браузеров разрабатывают собственные реализации. Одна из главных проблем с JavaScript – это то, что каждый движок ведет себя немного по разному. Стандартизация языка EMCAScript пытается сгладить все эти острые углы, чтобы Вы как разработчик меньше беспокоились по поводу поведения Вашего кода на разных платформах. К тому же эта конкуренция между движками привела к появлению оптимизированных решений, при которых скорость интерпретации кода JavaScript показывает впечатляющие результаты. Со временем на рынке движков хорошо себя зарекомендовали несколько решений: JaegerMonkey от Mozilla, Nitro от Apple и V8 от Google. После отказа от IE, новый браузер от Microsoft – EDGE также базируется на движке V8.
Node.js использует движок Google V8. Этот движок разрабатывается компанией Google начиная с 2006 года, преимущественно в Дании, при сотрудничестве с Орхусским Университетом. Основная область применения движка – Google Chrome браузер, где он отвечает за интерпретацию и выполнение JavaScript кода. Цель разработки нового движка V8 заключалась в том, чтобы повысить скорость интерпретации кода. Движок полностью реализует стандарт языка ECMAScript – ECMA-262 в пятой версии и в большей степени в 6 версии. Движок V8 полностью написан на С++, работает на различных платформах и доступен по BSD(распространение исходного кода Беркли) лицензии как программа с открытым исходным кодом для использования и улучшения всеми разработчиками. Например, Вы можете интегрировать движок в любое C++ приложение.
Обычно исходный код JavaScript не компилируется перед запуском, вместо этого файлы, содержащие исходный код, читаются(интерпретируются) напрямую когда приложение выполняется. Запуск приложения запускает Node.js процесс. Именно здесь начинается первая оптимизация со стороны движка V8. Исходный код не интерпретируется напрямую, сначала он транслируется в машинный код, а затем уже выполняется. Эта технология называется JIT(just-in-time – точно вовремя) компиляция и применяется для ускорения выполнения JavaScript приложений. Фактически приложение выполняется уже из скомпилированного машинного кода. В дополнение к JIT компиляции, V8 выполняет еще дополнительные оптимизации. К ним относятся оптимизированная и улучшенная сборка мусора и улучшенный контекст доступа к свойствам объекта. Для всех видов оптимизации, Вы должны помнить, что исходный код считывается при запуске приложения и дальнейшие изменения в исходниках не принесут никакого изменения в работающее приложение. Для того, чтобы изменения вступили в силу, Вы должны выйти и перезапустить приложение, чтобы измененные файлы были прочитаны снова.
1.6.1 Модель памяти
Главная цель разработки движка V8 состояла в том, чтобы максимально ускорить скорость выполнения исходного кода JavaScript. Для этих целей модель памяти также была оптимизирована. В движке V8 используются тэггированные указатели, которые представляют собой специально помеченные ссылки памяти. Все объекты в памяти выровнены п0 байтам, из которых 2 биты выделены для тэггированных указателей. В модели памяти V8 указатель всегда заканчивается на 01, в то время как обычное целочисленное значение заканчивается на 0. Этот способ позволяет быстро отличить указатель от целочисленных значений в памяти, что обеспечивает значительный прирост в производительности. Представление объекта в памяти движка V8 состоит из трех слов. Первое слово содержит ссылку на скрытый класс или объект, о которых вы узнаете в следующих разделах. Второе слово – указатель на атрибуты объекта(свойства объекта). И наконец третье слово ссылается на элементы объекта. Это свойства с числовым ключом. Данная структура поддерживает работу и оптимизацию движка JavaScript таким образом, что обращение к элементам памяти и поиск объектов в ней занимает совсем небольшое время.
1.6.2 Доступ к свойствам объекта
Как Вы уже наверное знаете, в JavaScript нет как такового понятия классов – объектная модель JavaScript основана на прототипах. В классово – ориентированных языках, таких как Java или PHP, классы представляют собой план(шаблон) объекта. Эти классы не могут быть изменены во время выполнения. Прототипы в JavaScript с одной стороны – динамические и могут менять свои свойства и методы во время исполнения. Во всех объекто-ориентированных языках программирования состояние объекта представлено его свойствами и методами, где методы используются для взаимодействия с другими объектами, а свойства представляют собой состояние объекта. В приложении Вы обычно часто обращаетесь к свойствам различных объектов. Вдобавок методы в JavaScript также являются свойствам объектов, которые хранятся в функции. В языке JavaScript вы работаете исключительно со свойствами и методами, поэтому доступ к ним осуществляется достаточно быстро.
Прототипы в JavaScript
JavaScript отличается от таких языков, как C++, Java или PHP тем, что он не основан на классово-ориентированном подходе, а базируется на прототипах, как например язык Self. В JavaScript каждый объект имеет свойство прототипа и таким образом сам прототип. В JavaScript, как и в других языках, Вы можете создавать объекты. Однако для этой цели вы не используете классы в сочетании оператором new. Вы можете создавать объекты несколькими способами. Для этих целей Вы можете использовать функции конструкторов или метод Object.create
. Общее у этих методов это то что вы создаете объект и назначаете ему прототип. Прототип – это объект, от которого другой объект наследует свойства. Другим преимуществом прототипов является то, что Вы можете изменять приложение прямо во время выполнения, добавляя новые свойства или методы. Используя прототипы Вы можете построить свою иерархию наследования в JavaScript.
Обычно в движке JavaScript, доступ к свойствам происходит через каталог в памяти. Таким образом, если Вы обращаетесь к свойству, выполняется поиск раздела памяти с соответствующим свойством в этом каталоге, и затем уже происходит доступ к значению. Теперь представим крупное Enterprise приложение, которое реализует JavaScript бизнес логику на стороне клиента, и в котором большое количество объектов хранятся параллельно в памяти, постоянно взаимодействуя друг с другом. Метод доступа к объектам через каталоги быстро превратился в большую проблему. Разработчики движка V8 быстро распознали это ошибку и разработали для нее решение – скрытые классы. Реальная проблема с JavaScript заключается в том, что структура объектов известна только во время выполнения приложения, а не во время компиляции, поскольку такой JavaScript процесс еще не существует. И осложняется эта проблема тем, что прототипов не один, а несколько – и они могут состоять в цепочке наследования. В классических языках структура объекта не меняется во время выполнения программы, свойства объектов всегда находятся в одном месте, что значительно ускоряет скорость доступа к ним.
Скрытый класс – это не что иное, как описание, какие свойства объекта могут быть найдены в памяти. С этой целью, скрытый класс назначается каждому объекту. Скрытый класс содержит указатель к секции памяти, где хранится соответствующее свойство. Как только вы получаете доступ к свойству объекта, для этого свойства создается скрытый класс, который повторно используется при каждом последующем доступе. Таким образом, для объекта потенциально существует отдельный скрытый класс для каждого свойства.
В листинге 1.1 Вы можете посмотреть пример, который показывает как работает скрытый класс:
class Person { constructor(firstname, lastname) { this.firstname = firstname; this.lastname = lastname; } } const johnDoe = new Person("John", "Doe");
Листинг 1.1 Доступ к свойствам в классе
В этом примере, Вы создаете новую функцию – конструктор для группы объектов Person. Конструктор имеет 2 параметра – имя и фамилию человека. Эти два значения хранятся в свойствах объекта firstname
и lastname
соответственно. Когда создается новый объект с этим конструктором при помощи оператора new
, первоначально создается скрытый класс – 0 класс. Он еще не содержит никаких указателей на свойства. После того как выполнено первое присвоение, то есть установили firstname
, создается новый скрытый класс – класс 1, созданный на базе класса 0. Теперь класс 1 содержит ссылку на область памяти свойства firstname
, относительно начала пространства имен объекта. Теперь, если добавлено свойствоfirstname,
к классу 0 добавлен переход класса, который указывает что класс 1 следует использовать вместо класса 0. Тот же самый процесс происходит и при выполнении присвоения для свойства lastname
. Создается скрытый класс 2, основанный на классе 1, который содержит ссылку на область памяти свойств firstname
и lastname
и добавляет переход, указывающий что класс 2 должен быть использован когда используется свойство lastname
. Если свойства добавляются вне конструктора, и происходят в другом порядке – в каждом случае создаются новые скрытые классы. Рисунок 1.3 поясняет этот процесс.
При первом доступе к свойствам объекта, использование скрытых классов не дает каких либо преимуществ в скорости. Однако все последующие обращения к свойству объекта проходят гораздо быстрее, потому что V8 может напрямую использовать скрытые классы объекта, которые содержат ссылку с областью памяти свойства.
1.6.3 Генерация Машинного кода
Как Вы уже знаете, V8 не занимается интерпретацией машинного кода напрямую, а выполняет предварительную JIT компиляцию в машинный код, для увеличения скорости выполнения приложения. Во время JIT компиляции исходный код не подвергается оптимизации. Исходный код, написанный программистом, преобразуется один в один. Помимо JIT компилятора, V8 содержит еще один компилятор, который способен оптимизировать машинный код. Для определения участков кода, которые требуют оптимизации, движок ведет внутреннюю статистику с количеством вызовов каждой функции и временем выполнения каждой из них. На основании этих данных принимается решение о том, какие функции требуют оптимизации.
Теперь у Вас возникнет вполне резонный вопрос, почему весь исходный код приложения не компилируется вторым компилятором? Потому что скорость компиляции исходного кода JIT компилятором имеет критически важное значение для разработчика. И именно поэтому оптимизируются только те участки кода, которые имеют имеют значение с точки зрения повышения производительности. Оптимизация машинного кода оказывает положительный эффект в основном на крупные и долго-выполняющиеся приложения, в которые функции вызывают больше, чем один раз.
О скрытых классах и внутреннем кешировании мы рассказали уже выше. После создания машинного кода и запуска приложения, V8 при каждом доступе к свойству ищет связанный с ним скрытый класс. Дальнейшая оптимизация заключается в том, что движок предполагает, что объекты будут иметь те же скрытые классы в будущем и поэтому он модифицирует машинный код с расчетом на неизменность скрытых классов. В следующий раз при попытке доступа к свойству объекта, доступ к нему можно будет получить без необходимости поиска соответствующего ему скрытого класса. Если используемый объект не имеет скрытого класса, V8 обнаруживает этот факт, удаляет ранее сгенерированный машинный код и заменяет его исправленной версией. У этого подхода есть критическая проблема: представьте что вы имеете код, где два разных объекта с разными скрытыми классами попеременно используются в приложении. В этом случае оптимизация с предсказанием следующего скрытого класса не сработает при следующем выполнении. В этом случае используются различные участки кода, которые не могут использоваться для нахождения раздела памяти так быстро, как при использовании скрытых классов, но код в этом случае выполняется гораздо быстрее, чем без оптимизации потому что движок выбирает искомый класс из очень небольшого набора скрытых классов. Генерация машинного кода и скрытых классов в сочетании с механизмом кэширования предоставляет широкие возможности по оптимизации, доступные в классо – ориентированных языках.
1.6.4 Сборщик мусора
Оптимизации, описанные выше влияют на скорость приложения. Еще одной важной особенность движка V8 является сборщик мусора. Сборка мусора относится к процессу очистки области памяти приложения в основной памяти. Элементы, которые больше не используются , удаляются из памяти, чтобы освободившееся пространство снова стало доступным для приложения.
Если Вам интересно, зачем вообще нужен сборщик мусора в JavaScript, когда сборщики мусора применятся в основном в сложных языках, типа Java – ответ довольно прост – первоначально язык предназначался для небольших веб-страниц страниц. Эти веб страницы и JavaScript код на этих страницах имели небольшое время жизни, до тех пора пока страница не перезагружалась и объекты, содержащиеся на этой странице очищались. Чем дольше выполняется JavaScript код на странице и чем сложнее выполняющиеся задачи, тем больше риск того, что память будет полностью заполнена объектами, которые уже больше не нужны в приложении. Если Вы при разработке предполагаете, что Ваше Node.js приложение будет работать несколько дней, недель или месяцев без остановки – тогда проблема будет очевидна. (примечание переводчика – в моей практике были Java EE приложения, которые по году без перезапуска сервера или самого приложения работали). Сборщик мусора V8 включает в себя набор функций, которые позволяют выполнять задачи сборки быстро и эффективно. В действительности, когда сборщик мусора начинает свою работу и до того момента как он останавливается, выполнение приложения полностью приостанавливается, и только после остановки сборки, возобновляется. Эти остановки в работе приложения длятся несколько миллисекунд и обычно пользователь физически не способен заметить их, так что негативный эффект от сборки мусора сходит на нет. Для того, чтобы прерывания работы сборщиком мусора были максимально коротки, очищается не вся память, а только ее часть. В дополнение V8 знает в любой момент времени, где в памяти находятся какие объекты и указатели.
Движок разделяет всю доступную память на 2 области – одна область для хранения объектов, а вторая область для хранения информации о скрытых классах и выполняющемся машинном коде. Процесс сборки мусора предельно прост. Когда приложения выполняется, объекты и указатели создаются в области памяти движком V8 с непродолжительным временем жизни. Когда эта область памяти заполняется – она очищается. Объекты, которые больше не используются – удаляются, а объекты, которые часто используются перемещаются в область памяти с длительным сроком жизни. При этом перемещении, корректируются указатели на новое место хранения объекта. Разделение памяти на краткосрочную и долгосрочную делает необходимым создание нескольких реализаций сборщика мусора .
Самый быстрый сборщик мусора – это тот, который быстро и эффективно очищает кратскосрочную область память. Для области долгосрочной памяти существует два разных сборщика мусора, они основаны на маркировка и очистке. Первый заключается в том, что просматривается вся память и ищутся элементы, которые больше не используются, далее помечаются маркером на удаление и после удаляются. Проблема с этим алгоритмом очистки в том, что он создает много свободных элементов в памяти, что при длительной работе вызывает неполадки с работой платформы. Для этого, второй алгоритм также выполняет поиск по элементам в памяти, ищет которые не нужны, помечает их и затем удаляет.
Разница между этими двумя алгоритмами заключается в том, что второй помимо поиска и удаления элементов, также выполняет дефрагментацию памяти с той целью, чтобы сократить количество свободных элементов, объединяя их друг с другом. Эта дефрагментация возможна только потому что V8 знает все объекты и указатели на них. При всех своих достоинствах, у процесса сборки мусора в долгосрочной памяти есть один недостаток – он более продолжителен по времени. Максимальное время сборки мусора составляет 2 мс. затем происходит помечание и очистка без оптимизации – она занимает 50 мс. И уже в конце идет процесс помечания и очистки с отпимизацией и дефрагментацией, со средней продолжительностью 100 мс.
В следующем разделе Вы узнаете больше о других элементах платформы V8, помимо движка.