Автор оригинала: Майя Лекова и Бенедикт Мёрер
Переводчик: Джоти, UC International R&D
Спереди написано: Добро пожаловать в официальный аккаунт «UC International Technology», мы предоставим вам качественные технические статьи, связанные с клиентом, сервером, алгоритмом, тестированием, данными, интерфейсом и т. д., не ограничиваясь оригинальностью и перевод.
Асинхронная обработка JavaScript всегда имела плохую репутацию из-за недостаточной скорости. Что еще хуже, отладка активных приложений JavaScript — особенно серверов Node.js — непростая задача, особенно когда задействовано асинхронное программирование. К счастью, они меняются. В этой статье рассматривается, как мы оптимизируем асинхронные функции и промисы в V8 (и в некоторой степени в других движках JavaScript), и описывается, как мы можем улучшить процесс отладки асинхронного кода.
Уведомление:Если вам нравится читать статьи во время просмотра выступлений, посмотрите видео ниже! Если нет, пропустите видео и читайте дальше.
Адрес видео:
https://www.youtube.com/watch?v=DFP5DKDQfOc
Новый подход к асинхронному программированию
>> От обратных вызовов к обещаниям и асинхронным функциям<<
До реализации промисов в JavaScript решение асинхронных проблем обычно основывалось на обратных вызовах, особенно в Node.js. Например 🌰:
Мы часто называем этот шаблон использования глубоко вложенных обратных вызовов «адом обратных вызовов», потому что код нелегко читать и сложно поддерживать.
К счастью, теперь, когда обещания являются частью JavaScript, мы можем реализовать наш код более элегантным и удобным для сопровождения способом:
Совсем недавно в JavaScript также была добавлена поддержка асинхронных функций. Теперь мы можем реализовать приведенный выше асинхронный код способом, приближенным к синхронному коду:
С асинхронными функциями выполнение кода по-прежнему асинхронно, но код чище и проще в реализации управления и потока данных. (Обратите внимание, что JavaScript по-прежнему выполняется в одном потоке, а это означает, что сам асинхронный метод не создает физический поток.)
>> От обратных вызовов прослушивателя событий к асинхронной итерации<<
Еще одна асинхронная парадигма, особенно распространенная в Node.js, — это ReadableStreams. См. пример:
Этот код немного сложен для понимания: входящие данные могут быть обработаны только в блоке кода обратного вызова, а сигнал об окончании потока также срабатывает внутри обратного вызова. Здесь легко писать ошибки, если вы не понимаете, что функция завершается немедленно, а фактическая обработка не происходит до тех пор, пока не будет запущен обратный вызов.
К счастью, классная новая функция ES2018, асинхронная итерация, упрощает этот код:
Вместо того, чтобы помещать логику для обработки фактического запроса в два разных обратных вызова — обратные вызовы «данные» и «конец», теперь мы можем поместить все в одну асинхронную функцию и использовать новую для ожидания. Цикл .of реализует асинхронную итерацию . Мы также добавили блоки try-catch, чтобы избежать проблем с необработанным отказом.[1].
Теперь вы можете официально использовать эти новые функции!Node.js 8 (V8 v6.2/Chrome 62) и выше полностью поддерживаются.асинхронный метод, в то время какNode.js 10 (V8 v6.8/Chrome 68) и выше полностью поддерживаются.Асинхронные итераторы и генераторы!
Асинхронное улучшение производительности
Мы значительно улучшили производительность асинхронного кода между V8 v5.5 (Chrome 55 и Node.js 7) и V8 v6.8 (Chrome 68 и Node.js 10). Разработчики могут безопасно использовать новые парадигмы программирования, не беспокоясь о скорости.
На изображении выше показан тест для doxbee, который измеряет производительность кода, активно использующего промисы. Обратите внимание, что на графике показано время выполнения, то есть чем меньше значение, тем лучше.
Результаты параллельных тестов с особым упором на производительность Promise.all() еще более впечатляющие:
Мы улучшили производительность Promise.all в 8 раз!
Однако приведенные выше тесты являются синтетическими микротестами. Команду V8 больше интересует, как эта оптимизация влияет на реальную производительность реального пользовательского кода.
На приведенной выше диаграмме показана производительность некоторых популярных сред ПО промежуточного слоя HTTP, которые активно используют промисы и асинхронные функции. Обратите внимание, что на этом графике показаны запросы в секунду, поэтому, в отличие от предыдущих графиков, чем выше число, тем лучше. Производительность этих платформ значительно улучшилась между Node.js 7 (V8 v5.5) и Node.js 10 (V8 v6.8).
Эти свойства улучшают результаты трех ключевых достижений:
TurboFan, новый оптимизирующий компилятор 🎉
Orinoco, новый сборщик мусора 🚛
Ошибка Node.js 8, из-за которой await пропускал микротики 🐛
После включения TurboFan в Node.js 8 наша производительность улучшилась по всем направлениям.
Мы работаем над новым сборщиком мусора под названием Orinoco, который может значительно улучшить обработку запросов, перенеся работу по сборке мусора из основного потока.
И последнее, но не менее важное: простая ошибка в Node.js 8 заставляла await пропускать микротики в некоторых случаях, что приводило к повышению производительности. Баг начался с непреднамеренного нарушения спецификации, но дал нам идею для оптимизации. Начнем с объяснения ошибки:
Приведенная выше программа создает выполненное обещание p и ожидает его результата, но также привязывает к нему два обработчика. В каком порядке вы хотите, чтобы вызовы console.log выполнялись?
Поскольку p уже выполнено, вы, вероятно, хотите, чтобы он печатал «after: await», а затем «tick». На самом деле Node.js 8 делает это:
в Node.js 8await
bug
Хотя такое поведение кажется интуитивно понятным, оно неверно, как говорится в спецификации. Node.js 10 реализует правильное поведение: сначала выполняются связанные обработчики, а затем продолжается асинхронная функция.
Это «правильное поведение», возможно, неочевидно, и это довольно неожиданно для разработчиков JavaScript 🐳, поэтому мы должны объяснить. Прежде чем мы погрузимся в удивительный мир промисов и асинхронных функций, давайте разберемся с некоторыми основами.
>> Task VS Microtask <<
В JavaScript есть понятие задачи и микрозадачи. Задачи обрабатывают события, такие как ввод-вывод и таймеры, по одному. Микрозадача реализует отложенное выполнение для async/await и обещаний и выполняется в конце каждой задачи. Всегда ждите, пока очередь микрозадач не опустеет, прежде чем возобновится выполнение цикла обработки событий.
Разница между задачей и микрозадачей
Дополнительные сведения см. в объяснении Джейка Арчибальда о задачах, микрозадачах, очередях и расписаниях в браузере. Модель задач в Node.js очень похожа.
Адрес статьи:
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
>> асинхронная функция<<
MDN определяет асинхронную функцию как функцию, которая использует неявное обещание для выполнения асинхронной операции и возврата ее результата. Асинхронные функции предназначены для того, чтобы асинхронный код выглядел как синхронный, что упрощает асинхронную обработку для разработчиков.
Простейшая асинхронная функция выглядит так:
При вызове он возвращает обещание, и вы можете получить его значение, как и любое другое обещание.
Значение этого обещания доступно только при следующем запуске микрозадачи. Другими словами, приведенная выше программа семантически эквивалентна использованию Promise.resolve для получения значения:
Настоящая сила Async-функций исходит от выражения ждут, которое приостанавливает выполнение функции, пока не будет выполнено обещание, а затем возобновляет выполнение функции. Значение ждут - это результат выполненного обещания. Этот пример хорошо объясняет:
fetchStatus приостанавливается в ожидании и возобновляется, когда обещание выборки завершается. Это более или менее эквивалентно связыванию обработчика с промисом, возвращаемым fetch.
Этот обработчик содержит код после ожидания в асинхронной функции.
Обычно вы бы ждали обещания, но вы можете ждать любое значение JavaScript. Даже если выражение после await не является обещанием, оно будет преобразовано в обещание. Это означает, что вы также можете ждать 42, когда захотите:
Что еще интереснее, await работает с любым «thenable», то есть с любым объектом с методом then, даже если это не настоящее промис. Таким образом, вы можете делать с ним интересные вещи, такие как асинхронный сон, который измеряет фактическое время сна:
Давайте проследим за спецификациями и посмотрим, что движок V8 делает с await. Вот простая асинхронная функция foo:
Когда Foo называется, он упакован в обещании и приостановить выполнение асинхронной функции, пока обещание не будет завершено. После завершения выполнение функции будет восстановлено, а W будет дана значение, когда обещание завершено. Асинхронная функция затем возвращается к этому значению.
>> Как ждут ручки V8<<
Во-первых, V8 помечает функцию как возобновляемую, что означает, что операцию можно приостановить и возобновить позже (в ожидании). Затем он создает что-то под названием implicit_promise , которое является обещанием, которое возвращается при вызове асинхронной функции и в конечном итоге разрешается в возвращаемое значение асинхронной функции.
Вот где становится интересно: настоящее ожидание. Во-первых, значение, переданное в await, завернуто в обещание. Затем поместите обработчик обработчика после промиса (чтобы возобновить асинхронную функцию после завершения промиса), и выполнение асинхронной функции будет приостановлено, возвращая implicit_promise вызывающей стороне. Как только обещание будет выполнено, сгенерированное значение w будет возвращено асинхронной функции, и асинхронная функция возобновит выполнение, а w является разрешенным результатом implicit_promise.
Короче говоря, начальные шаги await v таковы:
1. Оберните v — значение, переданное в await — в промис.
2. Прикрепите к обещанию обработчик для возобновления асинхронной функции позже.
3. Приостановить асинхронную функцию и вернуть вызывающему объекту implicit_promise.
Давайте сделаем это шаг за шагом. Предположим, что ожидание уже выполнено и возвращает 42. Затем движок создает новое обещание и завершает операцию ожидания. Это задерживает цепочку следующего раунда этих промисов, как указано в спецификации PromiseResolveThenableJob.
Затем движок создает еще одно обещание, которое называется throwaway (one-shot). Он называется одноразовым, потому что он не связан какой-либо цепочкой — он живет полностью внутри движка. Затем одноразовый объект будет привязан к обещанию с соответствующим обработчиком для возобновления асинхронной функции. Операция PerformPromiseThen неявно выполняется Promise.prototype.then() . Наконец, выполнение асинхронной функции приостанавливается, и управление возвращается вызывающей стороне.
Вызывающая программа продолжает выполнение до тех пор, пока стек вызовов не станет пустым. Затем движок JavaScript запускает микрозадачу: сначала он запускает предыдущий PromiseResolveThenableJob, порождая новый PromiseReactionJob, чтобы связать промис со значением, переданным в await . Затем механизм возвращается к обработке очереди микрозадач, поскольку очередь микрозадач должна быть очищена перед продолжением основного цикла обработки событий.
await
расходыПодводя итог тому, что мы узнали, для каждого ожидания движок должен создать два дополнительных обещания (даже если выражение справа уже является обещанием), и для его выполнения требуется как минимум три очереди микрозадач. Кто знал, что простое выражение ожидания может вызвать столько накладных расходов? !
Оказывается, в спецификации уже есть операция promiseResolve, которая выполняет обёртку только тогда, когда это необходимо:
Эта операция также возвращает промисы и оборачивает другие значения в промисы только в случае необходимости. Таким образом, вы сохраняете дополнительное обещание и два тика в очереди микрозадач, поскольку обычно значение, переданное в await, будет обещанием. Это новое поведение в настоящее время доступно с флагом --harmony-await-optimization V8 (начиная с V8 v7.1). Мы также отправили это изменение в спецификацию ECMAScript, и исправление будет применено, как только мы подтвердим его веб-совместимость.
Ниже показано, как работает новый и улучшенный await шаг за шагом:
В конце концов, когда все выполнение JavaScript завершается, движок запускает микрозадачу, поэтому выполняется PromiseReactionJob. Это задание распространяет результат промиса на выброс и возобновляет выполнение асинхронной функции, возвращая 42 из ожидания.
await
overheadЭта оптимизация позволяет избежать необходимости создавать оболочку обещания, если значение, переданное в await, уже является обещанием, что уменьшает минимум трех микротиков до одного. Это поведение похоже на то, что было в Node.js 8, но теперь это уже не ошибка — это стандартизированная оптимизация!
Несмотря на то, что движок полностью встроен, это все еще ошибка, что он должен создавать одноразовые промисы внутри. Оказывается, одноразовые промисы нужны только для того, чтобы удовлетворить ограничения API внутренней операции PerformPromiseThen в спецификации.
Последняя спецификация ECMAScript решает эту проблему. Движку больше не нужно создавать ожидаемое одноразовое обещание — в большинстве случаев[2].
await
code before and after the optimizationsЕсли сравнить ожидание в Node.js 10 с ожиданием, которое может быть оптимизировано в Node.js 12, влияние на производительность будет примерно следующим:
Улучшение опыта разработки
Помимо производительности, разработчики JavaScript также заботятся о возможности диагностики и устранения проблем, что не так просто при работе с асинхронным кодом. Chrome DevTool поддерживает асинхронную трассировку стека, которая включает не только текущую синхронную часть, но и асинхронную часть:
Это очень полезно во время локальной разработки. Однако после развертывания приложения этот подход не работает. Во время посмертной отладки вы можете видеть только вывод Error#stack в файле журнала, но не какую-либо информацию об асинхронной части.
Недавно мы работали над асинхронными трассировками стека с нулевой стоимостью, которые обогащают свойство Error#stack вызовами асинхронных функций. «Нулевая стоимость» звучит захватывающе, не так ли? Как функция Chrome DevTools может обеспечить нулевую стоимость, если она сопряжена со значительными накладными расходами? Например, 🌰, где foo вызывает bar асинхронно, а bar генерирует исключение после ожидания обещания:
Запуск этого кода в Node.js 8 или Node.js 10 выводит:
Обратите внимание, что хотя вызов foo() вызывает ошибку, foo не является частью трассировки стека. Из-за этого разработчикам JavaScript сложно выполнять посмертную отладку независимо от того, развернут ли ваш код в веб-приложении или внутри облачного контейнера.
Интересно, что когда бар заканчивается, движок знает, где он должен продолжаться: сразу после ожидания в функции foo. По совпадению, здесь также приостановлена функция foo. Механизм может использовать эту информацию для восстановления частей асинхронной трассировки стека, точек ожидания. С этим изменением вывод становится:
В трассировке стека первой идет самая верхняя функция, затем остальная часть синхронной трассировки стека, а затем асинхронный вызов bar в функции foo. Это изменение было реализовано в V8 после нового флага --async-stack-traces.
Однако, если вы сравните это с асинхронной трассировкой стека в Chrome DevTools выше, вы заметите, что фактический сайт вызова для foo отсутствует в асинхронной части трассировки стека. Как уже упоминалось, этот подход использует принцип, согласно которому ожидание возобновляется и приостанавливается в одном и том же месте, но это не относится к обычным вызовам Promise#then() или Promise#catch(). См. объяснение Матиаса Байненса о том, почему await превосходит Promise#then() для получения дополнительной информации.
В заключение
Благодаря следующим двум важным оптимизациям наша асинхронная функция работает быстрее:
убрать две лишние микрогалочки;
отменить одноразовое обещание;
Кроме того, мы улучшили процесс разработки с помощью асинхронной трассировки стека с нулевой стоимостью, которая запускается внутри await и Promise.all() асинхронных функций.
У нас также есть несколько отличных советов по производительности для разработчиков JavaScript:
Используйте асинхронные функции и ожидание вместо рукописных промисов;
Придерживайтесь нативной реализации обещаний, предоставляемой движком JavaScript, и избегайте использования ожидания с использованием двух микротиков;
Английский оригинал: https://v8.dev/blog/fast-async
Хорошая рекомендация статьи:
Анонсирована дорожная карта React 16.x, включая серверные компоненты Suspense и хуки
«UC International Technology» стремится делиться с вами высококачественными техническими статьями.
Добро пожаловать, чтобы подписаться на наш официальный аккаунт и поделиться статьей с друзьями