Как работает JavaScript: часть первая

Внутреннее устройство JavaScript и движка V8: что нужно знать, чтобы писать быстрый и правильный код.
23 октября 2017225057Андрей Никифоров4445115

Здравствуйте!

JavaScript — самый популярный язык программирования в репозиториях Гитхаба. Любой фронтенд-разработчик имеет с ним дело, а Node.js активно используется в бэкенд-разработке. Но понимаете ли вы, как на самом деле устроен JavaScript?

Это перевод сразу двух статей о внутреннем устройстве языка. Первая часть — общий обзор движка, среды выполнения и стека вызовов. Вторая часть — о том, как устроен V8 и как он оптимизирует код. В конце — советы по оптимизации кода для разработчиков.

Если вы в целом понимаете, как работает стек вызовов и петля событий в JavaScript, советую пропустить первую часть и перейти сразу к V8. Первая часть будет интереснее тем, кто только начинает изучать язык.

Оригинальные статьи: «How JavaScript works: an overview of the engine, the runtime, and the call stack» и «How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code».

Как работает JavaScript: движок, рантайм, стек вызовов

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

Прим. переводчика: вот актуальная статистика по языкам: madnight.github.io/githut

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

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

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

Если вы относительно недавно знакомы с JavaScript, этот пост поможет вам понять, почему JavaScript такой «странный» относительно других языков. А если вы уже опытный JavaScript–разработчик, я надеюсь, что статья покажет что-то новое о том, как работает язык, с которым вы работаете каждый день.

Движок JavaScript

Популярный пример движка JavaScript — это V8 от Google. Он используется, к примеру, внутри Chrome и Node.js. Вот сильно упрощенная модель того, как он выглядит изнутри:

Движок состоит из двух основных частей:

  • Куча — место, где происходит выделение памяти
  • Стек вызовов — место, где выполняется код, организованный в кадры вызовов

Среда выполнения

В браузере есть куча API, которыми пользуется почти каждый разработчик, например setTimeout. Эти API, однако, не предоставляются движком. Так откуда они берутся? Оказывается, что реальность несколько сложнее.

Итак, у нас есть движок, но кроме этого еще куча всего. У нас есть эти штуки, которые мы называем Web API, предоставляемые браузером, всякие DOM, AJAX, setTimeout и еще много всего. И еще у нас есть петля событий и очередь обратных вызовов.

Стек вызовов

JavaScript — однопоточный язык, и это значит — один стек вызовов. Иными словами, одна операция в один момент времени.

Стек вызовов — структура данных, которая попросту хранит информацию о том, какой участок кода выполняется сейчас. Если мы входим в функцию, мы помещаем ее в стек. Когда мы возвращаемся из функции, мы удаляем ее из стека. Это все, что делает стек.

Давайте разберемся на примере. Посмотрите на код:

function multiply(x, y) {
  return x * y;
}

function printSquare(x) {
  var s = multiply(x, x);
  console.log(s);
}

printSquare(5);

В начале выполнения стек пустой. После этого идут такие шаги:

Каждый элемент в стеке называется кадром вызова. Стектрейс, который выводится при выбрасывании исключения — по сути состояние стека вызовов на момент исключения. Посмотрите код:

function foo() {
  throw new Error('SessionStack will help you resolve crashes :)');
}

function bar() {
  foo();
}

function start() {
  bar();
}

start();

В Chrome вы увидите такую картинку:

«Раздувание стека» — это то, что случается, когда стек увеличивается до его максимума. И это может произойти очень просто, особенно при использовании рекурсии без должного тестирования. Вот пример:

function foo() {
  foo();
}

foo();

Когда движок исполняет этот код, он сперва вызывает функцию «foo». Эта функция рекурсивна, и постоянно вызывает саму себя без условия остановки. Так что на каждом шаге одна и та же функция добавляется в стек вызовов. Вот как это выглядит:

В какой-то момент количество вызовов в стеке превышает размер стека, и браузер решает вмешаться, выбросив исключение, которое выглядит примерно так:

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

Одновременность и петля событий

Что произойдет, если в стеке есть вызов функции, требующей много времени на выполнение? Например, представьте, что вы хотите сделать какую-то сложную обработку изображения, используя JavaScript в браузере.

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

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


Не самый хороший UX, а?

Так как мы можем выполнять тяжелый код без блокировки интерфейса и зависания браузера? Ну, решение — асинхронные обратные вызовы.

Прим. переводчика: в оригинальной статье в конце идет ссылка на сервис SessionStack. Так как этот перевод объединяет две статьи, я помещу ссылку на сервис в конце. Кроме того, в конце статьи автор обещает рассказать про асинхронные обратные вызовы во второй статье цикла, но по факту этого не делает. Рассказ об асинхронных вызовах и петле событий — тема четвертой статьи, которую я переведу чуть позже.

Как работает JavaScript: устройство V8 и пять советов по оптимизации кода

Чуть выше мы разобрались с тем, как работает стек вызовов, и какие проблемы могут с ним быть у неопытных разработчиков. Теперь мы поговорим о движке V8. Движок JavaScript — программа, интерпретирующая код JS. Он может быть реализован как стандартный интерпретатор, или как JIT-компилятор байткода.

Вот список популярных реализаций движка JavaScript:

  • V8  — движок с открытым кодом, написанный на C++ Гуглом.
  • Rhino — разработка Mozilla Foundation, написан на Java, код открыт.
  • SpiderMonkey — первый движок JavaScript, который когда-то использовался в Netscape Navigator, и теперь используется в Firefox.
  • JavaScriptCore — движок, используемый в Safari, также известен как Nitro, разработка Apple с открытым кодом.
  • KJS — движок, разработанный Гарри Портеном для браузера Konqueror
  • Chakra(JScript9) — Internet Explorer.
  • Chakra(JavaScript) — Microsoft Edge.
  • Nashorn — часть проекта OpenJDK, разработка Oracle с открытым кодом.
  • JerryScript — легковесный движок для IoT.

Зачем был создан V8

Движок V8, созданный Гуглом — разработка с открытым исходным кодом на C++. Движок используется в Google Chrome. В отличие от остальных движков, V8 используется также как рантайм Node.JS.

Изначально V8 был создан для улучшения производительности JavaScript внутри браузеров. В целях производительности V8 транслирует JavaScript в более эффективный машинный код вместо интерпретации. Он компилирует код в байткод на лету, используя JIT, аналогично некоторым современным движкам вроде SpiderMonkey и Rhino. Основная разница в том, что V8 не перегоняет в байткод любой промежуточный код.

Компиляторы V8

До версии 5.9, выпущенной в апреле 2017 года, движок использовал два компилятора:

  • full-codegen, простой и очень быстрый компилятор, производящий простой и относительно медленный байткод.
  • Crankshaft — более сложный JIT–компилятор, производящий сильно оптимизированный байткод.

В V8 также используется несколько потоков выполнения:

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

При первом выполнении кода V8 использует full-codegen, который напрямую транслирует код в байткод без какой-либо оптимизации. Это позволяет запуститься очень быстро. Заметим, что V8 не использует промежуточный байткод, что позволяет обойтись без интерпретатора.

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

Наконец, в дело вступает Crankshaft в отдельном потоке. Он транслирует абстрактное синтаксическое дерево в SSA (static single-assignment representation), называемое Hydrogen, и пытается оптимизировать полученный граф. Большинство оптимизаций происходят на этом уровне.

Прим. переводчика: я не нашел адекватной и развернутой статьи по SSA на русском языке. Если у вас нет проблем с английским, взгляните на материал по SSA «Static Single Assignment Book», в противном случае придется ограничиться статьей в Википедии.

Встраивание

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

Скрытый класс

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

Большинство JavaScript–интерпретаторов используют структуры типа словарь, основанные на хеш-функциях, чтобы хранить свойства объектов в памяти. Такие структуры делают получение значений свойств более дорогим, чем в статических языках вроде Java или C#. В Java все свойства объектов определяются фиксированной схемой объекта (классом) до этапа компиляции, и эта схема не может быть изменена в процессе выполнения. В C# есть тип dynamic, но это тема отдельного разговора.

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

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

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

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(1, 2);

Как только выполнение дойдет до строки «new Point(1, 2)», V8 создаст скрытый класс C0.

Пока у Point нет свойств, так что C0 пока пуст.

Как только первое выражение «this.x = x» выполнится внутри функции Point, V8 создаст второй скрытый класс C1, основанный на C0. С1 описывает то, где в памяти находится значение свойства x относительно указателя на объект. В нашем случае x сохранен по смещению 0, то есть при поиске в памяти объекта первое смещение будет соответствовать свойству x. Кроме того, V8 обновит класс C0 с помощью «классового перехода»: если к объекту Point добавится свойство x, следует опираться на скрытый класс C1. Нашему экземпляру объекта Point также соответствует класс C1.

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

Аналогичный процесс повторится на выражении «this.y = y».

Переходы между скрытыми классами основаны на порядке добавления свойств. Взгляните на кусок кода:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;

var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;

Вы можете предположить, что для p1 и p2 будет использован один скрытый класс и один переход. Ну, не совсем. Для p1 сначала добавлено свойство a, потом b. Для p2, напротив, сначала b, потом a. Так что в итоге p1 и p2 будут использовать разные скрытые классы, и разные пути переходов. Будет разумно по возможности инициализировать динамические свойства в одном и том же порядке для переиспользования динамических классов.

Встроенное кеширование

V8 использует еще один подход для оптимизации динамически типизированных языков, известный как встроенное кеширование. Он основан на наблюдении повторных вызовов одного и того же метода для одного и того же типа объекта. Подробное объяснение принципов работы можно прочесть в материале «Optimizing dynamic JavaScript with inline caches». Мы рассмотрим общие принципы встроенного кеширования, на случай, если у вас нет времени читать подробный разбор.

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

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

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

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

Компиляция в машинный код

Как только Hydrogen граф будет оптимизирован, Crankshaft преобразует его в низкоуровневое представление под названием Lithium. Большинство реализаций Lithium специфичны для конкретной архитектуры. Распределение регистров происходит на этом уровне.

В итоге Lithium компилируется в машинный код. Затем происходит операция под названием on-stack replacement, OSR. Прежде чем движок начнет компилировать и оптимизировать очевидно долгоиграющие методы, мы скорее всего уже запустим их. V8 не собирается забывать то, что он уже выполнял, и просто запустить оптимизированные версии. Вместо этого он трансформирует контекст, стек и регистры, так что мы можем переключиться на оптимизированную версию прямо в процессе выполнения. Это действительно сложная задача, учитывая то, что в числе прочих оптимизаций V8 уже встроил некоторое количество кода. Но V8 не единственный движок, способный на это.

Существует защитный механизм, называемый деоптимизацией. Он проводит обратное преобразование, возвращая обратно неоптимизированный код в случае, если движок ошибся с предположением.

Сборка мусора

Для сборки мусора V8 использует традиционный подход поколений и mark–and–sweep для очистки старого поколения. Фаза разметки предполагает приостановку выполнения кода. В целях контроля затрат на сборку и более плавной работы V8 использует постепенную разметку: вместо обхода всей кучи в попытках пометить каждый объект, он обходит часть кучи и продолжает нормальную работу. Следующая сборка начнется с места, где остановилась предыдущая. Это позволяет делать небольшие паузы в процессе работы. Как мы выяснили выше, фаза очистки происходит в отдельном потоке.

Ignition и TurboFan

Начиная с версии 5.9 в V8 появится новый подход к выполнению кода. Он обеспечит большую производительность и значительно меньший расход памяти на реальных задачах. Новый V8 построен поверх Ignition, интерпретатора V8 и TurboFan, нового оптимизирующего компилятора. Вы можете узнать больше об этом из поста в блоге разработчиков V8.

С версии 5.9 full-codegen и Crankshaft больше не будут использоваться в V8, а разработчики движка постараются реализовать новые возможности JavaScript и соответствующие оптимизации. Это значит, что в целом V8 получит более простую и поддерживаемую архитектуру.


Бенчмарк новой версии

Эти улучшения — только начало. Ignition и TurboFan проложат путь для дальнейших оптимизаций, которые увеличат производительность JavaScript и уменьшат размер движка в Chrome и Node.JS.

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

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

  1. Порядок добавления свойств: объявляйте свойства в том же порядке для однотипных объектов, чтобы они переиспользовали одни и те же скрытые классы.
  2. Динамические свойства: добавление свойств к объекту во время выполнения заставляет движок построить новый скрытый класс и лишает все предыдущие скрытые классы уже готовой оптимизации. По возможности объявляйте все свойства в конструкторе.
  3. Методы: код, в котором один и тот же метод выполняется несколько раз подряд, быстрее того, где всегда выполняются разные методы. Причина — встроенное кеширование.
  4. Массивы: избегайте sparse-массивов, в которых ключи — не последовательные числа. Sparse-массивы, которые не содержат каждый элемент внутри — по сути хеш-таблицы. Доступ к элементам таких массивов обходится дороже. Кроме того, старайтесь не объявлять сразу большие массивы. Увеличение массивов по необходимости обойдется дешевле. И наконец, не удаляйте элементы из массивов, потому что это превратит их в sparse-массивы.
  5. Помеченные значения: V8 представляет объекты и целые числа 32-мя битами, и использует один бит для хранения типа: объект это (flag=1) или число (flag=0). Таким образом, целые числа представляются как SMI (SMall Integer), поскольку на самом деле занимают только 31 бит. Если число больше 31 бита, V8 упаковывает его, преобразуя в double, и создает новый объект, помещая число внутрь. Старайтесь укладывать знаковые целые числа в 31 бит, чтобы избежать больших расходов на упаковку-распаковку.

Прим. переводчика: пятый пункт в оригинале звучит как «tagged values». Я не смог до конца понять, почему у этого пункта такое название, когда речь идет просто о накладных расходах на автоупаковку. Думаю, что в итоге это не помешает понять смысл совета.

В SessionStack мы стараемся следовать этим практикам для написания хорошо оптимизированного кода. Причина в том, что при интеграции SessionStack в ваше работающее приложение сервис записывает все: изменения DOM, взаимодействие с пользователем, исключения, стектрейсы, неудачные сетевые запросы и отладочные сообщения. С SessionStack вы можете воспроизвести проблемы вашего приложения и увидеть своими глазами все, что произошло, глазами пользователя. И все это — без влияния на производительность вашего кода.

Если вам интересно, у нас есть бесплатный тарифный план, чтобы можно было опробовать сервис.

Ресурсы

Это первые две из четырех статей цикла. Следующая статья — об устройстве памяти в JavaScript и борьбе с утечками памяти. Она выйдет в среду на этой неделе. Не переключайтесь!

Новые комментарии