Производительность JavaScript в 2021

Kate

Administrator
Команда форума
Меня зовут Шерстюк Юрий и за десять лет мне посчастливилось работать во многих командах, включая работу в Microsoft. Сейчас я участвую в разработке нескольких проектов в Intellias, а также постоянно выступаю с докладами и оказываю услуги консалтинга.

Данная статья может быть полезна инженерам, у которых не было времени разобраться в работе компиляторов и интерпретаторов. Также статья может заинтересовать тех, у кого JavaScript — первый язык программирования. Я буду поднимать вопросы Runtime производительности. Не DOM rendering, не Network latency, а именно Computing and Running JavaScript.

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

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

У меня есть особые чувства к JavaScript, поэтому и статья будет вокруг него. Хотя нет, также рассмотрим «Плюсы» в качестве антагониста всему, что происходит на скриптовых языках. Далее по тексту я намеренно буду упрощать некоторые темы, в ином случае, статья грозит стать по объему как одна из серии книг Толкина.

Что такое JavaScript​

Простой пример — объявление переменной на JS и C++:

image_90296126961634134890953.png


Всего одна строка кода, а сколько больших тем можно затронуть! Синтаксис тут не важен, а вот что действительно важно, так это отсутствие описания типа в JS. Более того, ту же переменную мы можем переопределить ниже в коде на абсолютно другой тип данных. Попробуй то же самое выполнить в C++. Если не брать некоторые эзотерические сценарии, то у тебя это не получится — компилятор упадет с ошибкой. Если инженер сам может указать тип данных, то он очень сильно упрощает написание компилятора/интерпретатора для такого языка. Почему? Потому что инженер прямо дает понять сколько нужно использовать физической памяти для данной переменной, но дело не только в этом.

Вот еще пример, но уже со ссылочным типом данных:

image_75860138271634134890999.png


Да, в C++ нам нужно сначала создать Класс, для того чтобы иметь экземпляр, но суть очевидна — мы не можем динамически добавлять свойства, не указав их типы в Классе, однако же JS дает нам такую возможность. Речь идет, не столько о противостоянии статической и динамической типизации, сколько интерпретируемого и компилируемого языков. Для компилятора C++ принципиально важно обладать всей информацией о типах прежде, чем написанный код превратиться в машинный код.

Обрати внимание, насколько эффективнее может быть машинный код, когда есть вся необходимая информация, чтобы его выполнить. Не секрет, что компилируемые языки производительнее в большинстве сценариев, но JS быстрый. Даже очень быстрый. Точнее быстрый не сам JS, а движки, которые его выполняют. И тут мы подошли к тому, как именно работают компиляторы и интерпретаторы.

Начнем с процесса компиляции​

image_41009722931634134890847.png


Задача: получить из исходного файла с кодом — исполняемый файл, который непосредственно запускает программу.

По шагам:

  1. Код инженера, написанный в соответствии с синтаксисом языка, попадает в препроцессор.
  2. Препроцессор — это макропроцессор, который преобразовывает программу для дальнейшего компилирования. На данной стадии происходит работа с препроцессорными директивами.
  3. Компилятор (Ahead-of-Time) — преобразует полученный на предыдущем шаге код без директив в ассемблерный код. То есть, это промежуточный шаг между высокоуровневым языком и машинным кодом (бинарным кодом).
    Ассемблер, в свою очередь, преобразовывает ассемблерный код в машинный код, сохраняя его в объектном файле (промежуточный файл, хранящий кусок машинного кода).
  4. Компоновщик (линкер) — связывает все объектные файлы и статические библиотеки в единый исполняемый файл.
  5. Загрузчик — нужен для загрузки нашей программы в память. На данной стадии также возможна подгрузка динамических библиотек.

Теперь рассмотрим интерпретацию​

image_67324697741634134890850.png


Не все интерпретаторы работают одинаково, но часто это выглядит так:

  1. Код инженера, написанный в соответствии с синтаксисом языка, попадает в парсер.
  2. Парсер преобразовывает код в абстрактное синтаксическое дерево (структура данных, выглядящая как ориентированное дерево, где вершины — операторы языка программирования, а листья — операнды).
  3. Абстрактное синтаксическое дерево переходит в интерпретатор, который преобразовывает его в байт-код (не путай с бинарным кодом).
    Байт-код — компактное, низкоуровневое, промежуточное представление кода, которое похоже на ассемблерный код, но в отличие от него платформо-независимое, потому что выполняется не для конкретной архитектуры процессора, а внутри виртуальной машины, которая, в свою очередь, универсальна для большинства архитектур.
  4. И тут, внимание, снова компилятор!
    Но, в данном случае, это JIT компилятор. В отличии от AOT компилятора, он работает во время выполнения программы (Just-In-Time) и транслирует часто используемые фрагменты байт-кода в машинный код, применяя при этом различные оптимизации.
  5. На выходе мы получаем машинный код, который в отличие от компиляции в C++ может вернуться обратно в JIT компилятор для кэширования или проведения дополнительных оптимизаций.
Компиляцию JS выполняют движки и сейчас среди них есть три крупных игрока: V8, SpyderMonkey и JavaScriptCore (он же SquirrelFish):

image_55543488251634134890874.png


Не могу не отметить, что движки имплементируют особенности языка в соответствии со стандартом ECMA-262, который регулируются комитетом TC39, поэтому полет фантазии по собственным решениям внутри движков довольно ограничен.

По сути V8 является наиболее популярным движком на рынке, во многом из-за использования его в NodeJS, поэтому рассмотрим его работу:

image_86828123021634134890845.png


То, что было представлено выше в схеме интерпретации языка повторяется. Тут важно отметить, что интерпретатор (Ignition) и компилятор (TurboFan) появились только в 2017 году, что стало большим прорывом. До этого компилятор работал не с байт-кодом, а на прямую с AST и это было очевидным бутылочным горлышком работы движка. Вот несколько преимуществ байт-кода над AST:

  • Байт-код занимает значительно меньший объем памяти.
  • Байт-код проще обрабатывать как более низкоуровневое представление, чем AST.
Сам TurboFan не менее прорывной и, в отличие от своих предшественников (Full-codegen и Crankshaft), он стал более модульным, что позволило вводить новые элементы из новых стандартов языка значительно легче.

Как насчет сборки мусора?​

Скорость работы движков — это, конечно, хорошо, но мы совсем не затронули работу с памятью. В самом начале я упоминал, что в JS есть возможность активно модифицировать типы данных, поэтому движкам нужно находить решения для работы с бесконтрольно используемой памятью и эту задачу решает Garbage Collector.

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

Фреймворки могут многое, но не все​

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

Непрошеный совет — очень рекомендую ознакомиться с такими идеями как мономорфные операторы, Hidden Classes, Bit Fields, Bloom Filters и так далее. Все они имеют значительное применение в построении современных фреймворков и для лучшего их понимания, можно начать с доклада Maxim Koretskyi — A sneak peek into super optimized code in JS frameworks.

И пара слов о TypeScript...​

Возможно, еще в первом примере данного текста ты подумал: «почему не использовать TS и закрыть вопрос с типизацией?». С одной стороны, ответ очевиден — TS является суперсетом JS и, в итоге, все равно будет преобразован в JS, поэтому для интерпретатора не будет особых отличий. Или отличия все-таки будут? У меня есть масса вопросов к TS, но нужно признать, что он стал очень популярен и, что более важно, он таки решает часть проблем, которые существуют в JS.

Пройдемся по положительным сторонам TS:

  • Кроме более строгой типизации, дает возможность использовать инструменты из более «взрослых» языков программирования (перегрузка функций, интерфейсы, дженерики и так далее).
  • Новые элементы стандарта языка, зачастую, сначала появляются в TS.
  • Когда TS-код преобразован в JS, он может быть более эффективным именно из-за того, что у TS-компилятора есть больше данных о типах (вспомни про мономорфные операторы, о которых я упоминал выше).
  • Большинство IDE умеют работать с TS, поэтому очень легко получать информацию о типах прямо при наведении на элемент кода.
Что в TS не совсем гладко:

  • Временами, компилятор TS выдает ошибки, которые сложно понять. От версии к версии TS, есть положительная динамика в этом вопросе.
  • Типы существуют только когда используется компилятор TS (AOT компиляция). Как только код уходит в Runtime, вся информация о типах теряется, потому что это уже JS и проверить динамически подгружаемые данные на типы мы не можем. Даже те типы, что были валидными в TS при компиляции, могут иметь совсем иные значения в Runtime и это большая проблема.
На последнем пункте хочу сделать акцент: сначала TS собирает и обрабатывает информацию о типах, потом эта информация теряется в Runtime и, затем, снова собирается на этапе работы движка. Возможно, в будущем интерпретаторы движков научат работать сразу с AST от TS, но пока имеем, что имеем.

На самом деле, теория типов — еще одна прекрасная тема для изучения, на которую стоит обратить внимание. Если хочешь сочетать это с практикой, то можно остановиться на ReasonML (суперсет OCaml). Типы в нем богаче, чем в TS и его тоже можно скомпилировать в JS. В сочетании с React, работает отлично (конечно, писал один и тот же человек — Jordan Walke). Можно сказать, что к ReasonML у меня академический интерес, потому что далеко не в любой проект получится интегрировать его без костылей.

Вместо заключения​

Все, что описано выше — скорее отправная точка, чем финальный результат, поэтому тут нет последнего слова. Уверен, когда ты открывал статью, рассчитывал увидеть нечто другое.

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

  • Используй статически типизированные языки/нотации/суперсеты, они ускорят работу кода и сделают его более стабильным.
  • Чем более иммутабельными будут данные, тем более предсказуемо будет выполняться код.
  • Упрости работу сборщику мусора, не создавай данные, на которые ссылается корневой объект. Хорошим тоном будет использование WeakMap и WeakSet, где это возможно.
  • Не пренебрегай инструментами профилирования. В JS они есть почти в любом Runtime.
  • Изучи алгоритмы и структуры данных, если еще этого не сделал. Это те знания, которые тебе пригодятся в любых IT технологиях.
Если ты дочитал до этого момента, значит я потратил время не зря и в эпоху «быстрого внимания» и «коротких текстов» статья вызвала у тебя интерес. Благодарю за твое время и надеюсь, это поможет нам с тобой и всей индустрии сделать следующий шаг!

P.S. Что почитать про компиляторы и интерпретаторы:

 
Сверху