open atlas
↑ К треку
Внутренности движка JavaScript JSE · 07 · 04

async/await, десахаризированный

async-функция возвращает Promise; await приостанавливает её, оборачивает операнд через PromiseResolve и возобновляет через микрозадачу, когда операнд урегулируется — генератор плюс драйвер. V8 7.2 (2018) убрал лишние тики await для нативных Promise

JSE Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Коллега настаивает: «await блокирует, пока данные не вернутся — в этом весь смысл». Тогда почему сервер продолжает обрабатывать 5 000 других запросов, пока один из них await’ит медленный запрос к базе? Потому что await ничего не блокирует. Он приостанавливает одну функцию и возвращает поток. Понимание разницы — приостанавливает, а не блокирует — это грань между тем, кто использует async/await, и тем, кто умеет отлаживать его под нагрузкой.

async/await — это генератор плюс драйвер

async/await — это чистый синтаксический сахар; движок десахаризирует его в корутину. async-функция механически — это генератор, чьи точки yield — это await’ы, в паре с автоматическим драйвером, который продвигает генератор каждый раз, когда await’ируемое значение урегулируется. Два факта выпадают сразу:

  1. async-функция всегда возвращает Promise. Её вызов выполняет тело синхронно до первого await, затем возвращает pending-Promise, представляющий «итоговое завершение этой функции». return x внутри — fulfils этот Promise значением x; брошенная ошибка — rejects его.
  2. await x — это точка приостановки. Когда управление доходит до await x, движок: оборачивает x через PromiseResolve (превращая любое значение или thenable в Promise, на который подписаться), регистрирует продолжение как реакцию на этом Promise и возвращается из функции — отдавая поток назад хосту. Когда await’ируемый Promise урегулируется, зарегистрированная реакция (микрозадача) возобновляет функцию сразу после await, подставив значение (или перебросив отклонение).

Поскольку возобновление — это микрозадача, async-функция с двумя await’ами — не одна синхронная единица: она нарезана на куски, которые чередуются с каждой другой ожидающей микрозадачей между возобновлениями. Это линза урока: await — это yield драйверу, а драйвер работает на очереди микрозадач.

История: await раньше стоил три тика

Исходная спецификация ES2017 для await была расточительной. Чтобы обработать случай, когда вы await’ите thenable, спецификация говорила: возьми операнд, создай совершенно новый одноразовый Promise, урегулируй его операндом, затем .then на нём, чтобы подписаться. Даже когда операнд уже был нативным Promise, вы платили за одноразовый Promise и его job принятия thenable. Стоимость, измеренная в тиках микрозадач до возобновления функции, была около трёх тиков для await p, где p — нативный Promise, против одного тика для эквивалентного p.then(...). В горячем цикле, полном await’ов, это утраивало трафик микрозадач.

// До V8 7.2, `await p` был примерно эквивалентен:
const throwaway = new Promise(resolve => resolve(p)); // принять p — лишние тики
throwaway.then(resume);                                // подписаться — ещё тик
// ≈ 3 тика микрозадач до возобновления, даже для нативного Promise p

V8 7.2 (2018): await ≈ p.then

V8 7.2 выпустил оптимизацию await. Когда await’ируемый операнд — нативный Promise (настоящий Promise, а не чужой thenable), движок полностью пропускает танец с одноразовым Promise и регистрирует реакцию-возобновление прямо на операнде — ровно как сделал бы p.then(resume). Это схлопывает await p с ~3 тиков до 1 тика, совпадая с написанным вручную .then. Затем TC39 изменил саму спецификацию под это (флаг --harmony-await-optimization гейтил это во время раскатки; теперь это поведение по умолчанию и спецификации). Практический итог для сеньоров: начиная с движков конца 2018 года, вы больше не платите налог в тик за использование await вместо .then на нативных Promise — пишите то, что читается яснее. Налог возвращается, только если вы await’ите не-нативный thenable, которому всё ещё нужен job принятия.

Стоимости тиков await по версиям V8
await нативного Promise (до 7.2)
~3 тика
await нативного Promise (7.2+, сегодня)
1 тик
p.then(resume) — всегда
1 тик
await не-нативного thenable
+тик принятия
Версия V8 с фиксом
7.2 (окт 2018)
Флаг во время раскатки
--harmony-await-optimization

Zero-cost async stack traces

Та же эпоха релизов принесла zero-cost async stack traces (трассировки стека без накладных расходов во время выполнения). Проблема: когда ошибка всплывает на три await’а вглубь, синхронный стек вызовов давно исчез — каждый await вернулся хосту, поэтому наивная трасса показывает лишь самое внутреннее возобновление без цепочки вызывающих. Старый фикс (оставлен под флагом) записывал стек при каждом создании Promise, что было дорого на горячих путях. Подход zero-cost вместо этого восстанавливает async-фреймы по требованию из цепочки Promise: V8 обходит ссылки [[PromiseFulfillReactions]] (из урока 3) назад, чтобы перестроить логический async-стек вызовов только когда DevTools реально его запрашивает (когда вы попадаете на точку останова или читаете error.stack в контексте отладки). Быстрый путь не платит ничего — никакой аллокации на await — и всё же вы получаете полную async-трассу в отладчике. «Zero-cost» означает нулевую стоимость, когда вы не отлаживаете.

Распространение ошибок: отклонение становится throw

Когда вы оборачиваете await в try/catch и он действительно ловит сетевую ошибку, возникает вопрос: как асинхронное отклонение попадает в синхронный catch-блок? Поскольку реакция-возобновление регистрируется и для fulfilment, и для rejection, await превращает асинхронное отклонение обратно в синхронно выглядящий throw внутри функции. Когда await’ируемый Promise отклоняется, движок возобновляет функцию, бросая причину отклонения в выражении await — поэтому try/catch вокруг await ловит её ровно так, как если бы синхронная функция бросила. В этом весь эргономический выигрыш async/await: линейная обработка ошибок поверх по своей природе нелинейного потока управления. Обратная сторона — ловушка из урока 3: не-await’нутый, не пойманный отклонённый Promise порождает unhandledrejection, потому что не была зарегистрирована реакция, чтобы превратить его в ловимый throw.

Викторина

Node-сервер await'ит медленный запрос к БД внутри одного обработчика запроса. Что происходит с 5 000 других запросов в полёте во время этого await?

Викторина

На современном V8 (7.2+), как стоимость в тиках микрозадач `await nativePromise` соотносится с `nativePromise.then(resume)`?

Расставь шаги по порядку

Расставьте по порядку, что делает движок, когда async-функция доходит до `await fetchUser()` и fetch позже fulfils.

  1. 1 Выполнить тело функции синхронно до выражения await
  2. 2 Обернуть операнд через PromiseResolve и зарегистрировать на нём продолжение-возобновление
  3. 3 Вернуть управление хосту, который свободен выполнять другие задачи
  4. 4 Когда await'ируемый Promise урегулируется, поставить продолжение как микрозадачу
  5. 5 Микрозадача выполняется: возобновить функцию после await с подставленным значением
Граничные случаи

Тонкое следствие «await = приостановка через микрозадачу»: await Promise.resolve() — чистый, идиоматичный способ отложить на один тик — он отдаёт управление очереди микрозадач и возобновляется после текущих ожидающих микрозадач. Но он не отдаёт управление хосту (никакого рендеринга, никакие макрозадачи не выполняются), потому что возобновление — микрозадача, а не макрозадача. Чтобы реально отдать управление рендерингу, нужна граница макрозадачи (setTimeout, MessageChannel, scheduler.yield()) — различие из урока 2, которое решает, сможет ли страница отрисоваться.

Вспомните перед уходом
  1. 01
    Десахаризируйте async/await: что возвращает async-функция и что именно делает await?
  2. 02
    Почему await когда-то стоил ~3 тика и что изменил V8 7.2?
  3. 03
    Как работают zero-cost async stack traces и что значит «zero-cost»?
Итог

async/await — синтаксический сахар, который движок десахаризирует в корутину: async-функция — это генератор, чьи await’ы — это yields, продвигаемый автоматическим драйвером. Её вызов выполняется синхронно до первого await и возвращает pending-Promise (return fulfils его, throw rejects). await x оборачивает x через PromiseResolve, регистрирует двойное продолжение fulfil/reject как реакцию и возвращает управление хосту — приостанавливая функцию, никогда не блокируя поток, поэтому один await’ящий запрос не застопоривает тысячи других. При урегулировании продолжение выполняется как микрозадача, возобновляя функцию после await со значением или перебрасывая отклонение (поэтому try/catch вокруг await работает, давая линейную обработку ошибок поверх нелинейного потока). Исторически await стоил около трёх тиков микрозадач, потому что спецификация принимала операнд через одноразовый Promise; V8 7.2 (октябрь 2018) сделал особый случай для нативных Promise, регистрируя возобновление напрямую, сократив await до одного тика — равно p.then — и TC39 принял это изменение. Та же эпоха принесла zero-cost async stack traces, которые восстанавливают async-фреймы из цепочки Promise по требованию, поэтому быстрый путь ничего не платит. Это закрывает юнит async-deep: от движка без цикла, через две очереди, в конечный автомат Promise и, наконец, в корутину await, построенную поверх него. Теперь, когда встретишь await на горячем пути, умеешь рассуждать о нём: один тик до возобновления для нативного Promise, поток свободен между приостановкой и возобновлением, а try/catch поймает то, что бросил отклонённый Promise — не фольклор, механизм.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки
опирается на
встречается в184

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.