Производительность JavaScript в 2021
Меня зовут Шерстюк Юрий и за десять лет мне посчастливилось работать во многих командах, включая работу в Microsoft. Сейчас я участвую в разработке нескольких проектов в Intellias, а также постоянно выступаю с докладами и оказываю услуги консалтинга.
Данная статья может быть полезна инженерам, у которых не было времени разобраться в работе компиляторов и интерпретаторов. Также статья может заинтересовать тех, у кого JavaScript — первый язык программирования. Я буду поднимать вопросы Runtime производительности. Не DOM rendering, не Network latency, а именно Computing and Running JavaScript.
Позволь мне фамильярность быть с тобой на ты. На профессиональных ресурсах ты сталкиваешься с техническими статьями на частые темы фреймворков, библиотек и архитектуры. Задумайся о том, что с появлением квантовых компьютеров мир никогда не будет прежним и сейчас лучшее время для того, чтобы закрыть пробелы в понимании процессов, которые работают глубоко внутри интерпретаторов и компиляторов. Не узнав этого, будет не просто понять, что такого прорывного в квантовых технологиях, да и не только в них.
В далеком 2018 я уже активно пытался раскачать сообщество в сторону изучения алгоритмов и структур данных и я искренне рад, что многие стали чаще интересоваться этой темой. Сейчас мой фокус направлен на то, как функционируют технологические системы.
У меня есть особые чувства к JavaScript, поэтому и статья будет вокруг него. Хотя нет, также рассмотрим «Плюсы» в качестве антагониста всему, что происходит на скриптовых языках. Далее по тексту я намеренно буду упрощать некоторые темы, в ином случае, статья грозит стать по объему как одна из серии книг Толкина.
Что такое JavaScript
Простой пример — объявление переменной на JS и C++:
Всего одна строка кода, а сколько больших тем можно затронуть! Синтаксис тут не важен, а вот что действительно важно, так это отсутствие описания типа в JS. Более того, ту же переменную мы можем переопределить ниже в коде на абсолютно другой тип данных. Попробуй то же самое выполнить в C++. Если не брать некоторые эзотерические сценарии, то у тебя это не получится — компилятор упадет с ошибкой. Если инженер сам может указать тип данных, то он очень сильно упрощает написание компилятора/интерпретатора для такого языка. Почему? Потому что инженер прямо дает понять сколько нужно использовать физической памяти для данной переменной, но дело не только в этом.
Вот еще пример, но уже со ссылочным типом данных:
Да, в C++ нам нужно сначала создать Класс, для того чтобы иметь экземпляр, но суть очевидна — мы не можем динамически добавлять свойства, не указав их типы в Классе, однако же JS дает нам такую возможность. Речь идет, не столько о противостоянии статической и динамической типизации, сколько интерпретируемого и компилируемого языков. Для компилятора C++ принципиально важно обладать всей информацией о типах прежде, чем написанный код превратиться в машинный код.
Обрати внимание, насколько эффективнее может быть машинный код, когда есть вся необходимая информация, чтобы его выполнить. Не секрет, что компилируемые языки производительнее в большинстве сценариев, но JS быстрый. Даже очень быстрый. Точнее быстрый не сам JS, а движки, которые его выполняют. И тут мы подошли к тому, как именно работают компиляторы и интерпретаторы.
Начнем с процесса компиляции
Задача: получить из исходного файла с кодом — исполняемый файл, который непосредственно запускает программу.
По шагам:
- Код инженера, написанный в соответствии с синтаксисом языка, попадает в препроцессор.
- Препроцессор — это макропроцессор, который преобразовывает программу для дальнейшего компилирования. На данной стадии происходит работа с препроцессорными директивами.
- Компилятор (Ahead-of-Time) — преобразует полученный на предыдущем шаге код без директив в ассемблерный код. То есть, это промежуточный шаг между высокоуровневым языком и машинным кодом (бинарным кодом).
Ассемблер, в свою очередь, преобразовывает ассемблерный код в машинный код, сохраняя его в объектном файле (промежуточный файл, хранящий кусок машинного кода). - Компоновщик (линкер) — связывает все объектные файлы и статические библиотеки в единый исполняемый файл.
- Загрузчик — нужен для загрузки нашей программы в память. На данной стадии также возможна подгрузка динамических библиотек.
Теперь рассмотрим интерпретацию
Не все интерпретаторы работают одинаково, но часто это выглядит так:
- Код инженера, написанный в соответствии с синтаксисом языка, попадает в парсер.
- Парсер преобразовывает код в абстрактное синтаксическое дерево (структура данных, выглядящая как ориентированное дерево, где вершины — операторы языка программирования, а листья — операнды).
- Абстрактное синтаксическое дерево переходит в интерпретатор, который преобразовывает его в байт-код (не путай с бинарным кодом).
Байт-код — компактное, низкоуровневое, промежуточное представление кода, которое похоже на ассемблерный код, но в отличие от него платформо-независимое, потому что выполняется не для конкретной архитектуры процессора, а внутри виртуальной машины, которая, в свою очередь, универсальна для большинства архитектур. - И тут, внимание, снова компилятор!
Но, в данном случае, это JIT компилятор. В отличии от AOT компилятора, он работает во время выполнения программы (Just-In-Time) и транслирует часто используемые фрагменты байт-кода в машинный код, применяя при этом различные оптимизации. - На выходе мы получаем машинный код, который в отличие от компиляции в C++ может вернуться обратно в JIT компилятор для кэширования или проведения дополнительных оптимизаций.
Компиляцию JS выполняют движки и сейчас среди них есть три крупных игрока: V8, SpyderMonkey и JavaScriptCore (он же SquirrelFish):
Не могу не отметить, что движки имплементируют особенности языка в соответствии со стандартом ECMA-262, который регулируются комитетом TC39, поэтому полет фантазии по собственным решениям внутри движков довольно ограничен.
По сути V8 является наиболее популярным движком на рынке, во многом из-за использования его в NodeJS, поэтому рассмотрим его работу:
То, что было представлено выше в схеме интерпретации языка повторяется. Тут важно отметить, что интерпретатор (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. Что почитать про компиляторы и интерпретаторы:
13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів