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

Внутри Promise

Promise — это конечный автомат, урегулируемый единожды, который хранит состояние, значение и список записей PromiseReaction. .then регистрирует реакции и возвращает новый Promise; урегулирование ставит по PromiseReactionJob на реакцию. Урегулирование thenable стоит лишнего тика.

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

Вы пишете await fetchUser() и await fetchPosts(), и кто-то спрашивает: сколько тиков микрозадач это стоило? Большинство инженеров пожмут плечами. Но ответ решает, добавляет ли горячий асинхронный путь один тик или четыре на каждую итерацию — а при 10 000 итераций на рендер этот разрыв и есть разница между плавным списком и дёрганым. Чтобы считать тики, нужно знать, что такое Promise внутри.

Promise — это три поля и список

Снимите API, и объект Promise в V8 окажется маленьким. Его существенные внутренние слоты:

  • [[PromiseState]] — одно из pending, fulfilled, rejected. Начинается с pending и переходит ровно один раз. Этот инвариант урегулирования единожды — сердце автомата: после ухода из pending никакой более поздний resolve/reject его не изменит.
  • [[PromiseResult]] — значение, которым он fulfilled, или причина, которой rejected. Бессмысленно, пока pending.
  • [[PromiseFulfillReactions]] / [[PromiseRejectReactions]] — два списка записей PromiseReaction, обработчиков, зарегистрированных через .then/.catch, пока Promise был ещё pending. После урегулирования эти списки очищаются и заменяются результатом.

Запись PromiseReaction связывает три вещи: тип (fulfill или reject), обработчик (ваш колбэк onFulfilled / onRejected или undefined) и capability нового Promise, который вернул .then (функции resolve/reject, которые урегулируют этот нижестоящий Promise). Эта последняя часть — причина, по которой сцепление вообще работает.

Что на самом деле делает .then

promise.then(onF, onR) делает три конкретные вещи по порядку:

  1. Создаёт новый Promise («result capability») и запоминает его функции resolve/reject.
  2. Строит две записи PromiseReaction — одну fulfill, одну reject — каждая оборачивает совпадающий обработчик и эту новую capability.
  3. Либо регистрирует, либо планирует их. Если исходный Promise ещё pending, реакции добавляются в его списки реакций и пока ничего не запускается. Если Promise уже урегулирован, .then не запускает обработчик синхронно — он немедленно ставит PromiseReactionJob как микрозадачу для совпадающей реакции.
const p = Promise.resolve(42);   // уже fulfilled значением 42
const q = p.then(v => v + 1);     // q — НОВЫЙ Promise; одна PromiseReactionJob поставлена сейчас
// q урегулируется значением 43 после того, как этот job выполнится (одной микрозадачей позже)

Итак, .then никогда не запускает ваш обработчик «прямо сейчас», даже на уже урегулированном Promise. Он всегда откладывает на микрозадачу. Эта единственная гарантия и делает порядок Promise предсказуемым: колбэк .then всегда отстоит на одну микрозадачу от урегулирования, никогда не синхронен.

Урегулирование ставит по одному job на реакцию

Когда продюсер вызывает функцию resolve (урегулируя Promise в fulfilled), движок выполняет FulfillPromise: устанавливает [[PromiseState]] в fulfilled, сохраняет значение, а затем обходит [[PromiseFulfillReactions]], ставя микрозадачу PromiseReactionJob для каждой зарегистрированной реакции. Каждый job, когда его выполнит чекпоинт микрозадач, вызывает обработчик реакции с результатом и использует возвращённое значение, чтобы урегулировать нижестоящий Promise этой реакции (у которого могут быть собственные реакции, каскадом порождающие новые jobs). Отклонение симметрично через RejectPromise и [[PromiseRejectReactions]].

Вот почему fan-out стоит N микрозадач: p.then(a); p.then(b); p.then(c) на урегулированном p регистрирует три реакции и ставит три PromiseReactionJob — a, b, c выполняются как три отдельные микрозадачи в порядке регистрации.

Стоимости тиков, которые надо уметь называть
Promise.resolve().then(fn) → fn запускается
1 тик
N .then на одном урегулированном Promise
N тиков
Цепочка .then().then().then()
1 тик на звено
resolve(promise) — принять thenable
+1 тик (resolve job)
resolve(не-нативный thenable)
+1 тик (thenable job)
await нативного Promise (V8 7.2+)
1 тик (было 3)

Лишний тик: урегулирование через thenable

Вот тонкость, спотыкающая подсчёт тиков. Если вы урегулируете Promise другим Promise (или любым thenable), движок не может принять его значение синхронно — thenable может урегулироваться позже. Поэтому ResolvePromise планирует PromiseResolveThenableJob: микрозадачу, единственная цель которой — вызвать .then внутреннего thenable, чтобы подписаться на него. Эта подписка сама по себе ещё одно откладывание. Следствие: урегулирование Promise A через Promise B добавляет лишний тик микрозадачи по сравнению с урегулированием A обычным значением, потому что движок должен отскочить через PromiseResolveThenableJob, прежде чем A сможет хотя бы начать принимать будущее состояние B.

// Обычное значение: q урегулируется одним тиком после запуска реакции p.
Promise.resolve(1).then(v => v);

// Thenable: возврат Promise из обработчика стоит лишнего тика,
// потому что внешний Promise должен принять внутренний через resolve job.
Promise.resolve(1).then(v => Promise.resolve(v));   // лишний PromiseResolveThenableJob

Это и есть механическая причина, по которой исходный await стоил три тика (разбирается в следующем уроке): спецификация оборачивала ожидаемое значение в одноразовый Promise и принимала его через thenable job. V8 7.2 (2018) сделал особый случай для нативных Promise, пропускающий эти лишние jobs.

Отслеживание необработанных отклонений

Движок также следит за отклонениями без reject-обработчика. Когда выполняется RejectPromise, а список reject-реакций пуст (не прикреплён .catch/.then(_, onR)), Promise помечается как имеющий потенциально необработанное отклонение. Хост уведомляется через HostPromiseRejectionTracker, что и порождает браузерное событие unhandledrejection и process.on('unhandledRejection') в Node. Важно, что это отложено: обработчик, прикреплённый позже в том же обороте, снимает флаг (порождает rejectionhandled), потому что прикрепление .catch регистрирует reject-реакцию, которая потребляет сохранённое отклонение. Это откладывание — причина, по которой «добавьте .catch на следующей строке» всё ещё подавляет предупреждение — а вот .catch, прикреплённый макрозадачей позже, уже нет.

Викторина

Что создаёт `const q = p.then(fn)`, и когда запустится `fn`, если `p` уже fulfilled?

Викторина

Почему урегулирование Promise `A` другим Promise `B` стоит лишнего тика микрозадачи против урегулирования `A` числом 5?

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

Расставьте по порядку, что делает движок от вызова `.then` на ещё pending-Promise до запуска обработчика после урегулирования Promise.

  1. 1 .then создаёт новый нижестоящий Promise и запоминает его resolve/reject
  2. 2 .then строит записи PromiseReaction и добавляет их в списки pending-Promise
  3. 3 Продюсер вызывает resolve(); FulfillPromise сохраняет значение и ставит состояние fulfilled
  4. 4 FulfillPromise ставит микрозадачу PromiseReactionJob для каждой зарегистрированной реакции
  5. 5 Чекпоинт микрозадач выполняет каждый job: обработчик запускается и урегулирует нижестоящий Promise
Почему это работает

Зачем заставлять .then всегда откладывать на микрозадачу, даже когда Promise уже урегулирован? Потому что функция, которая иногда вызывает свой колбэк синхронно, а иногда асинхронно, неподдаётся рассуждению — печально известное «выпускание Zalgo». Гарантируя, что обработчик всегда запускается в более поздней микрозадаче, Promise дают вам один инвариант, на который можно опереться: код после .then(...) на текущей строке всегда выполняется до обработчика.

Вспомните перед уходом
  1. 01
    Опишите внутреннюю структуру Promise и инвариант урегулирования единожды.
  2. 02
    Пройдите всё, что делает .then, и объясните, почему обработчик никогда не синхронен.
  3. 03
    Объясните лишний тик от урегулирования Promise другим Promise и свяжите его с await.
Итог

Promise — компактный конечный автомат, урегулируемый единожды. Внутри это слот состояния ([[PromiseState]]: pending → fulfilled | rejected, в одну сторону), слот результата ([[PromiseResult]]) и два списка записей PromiseReaction ([[PromiseFulfillReactions]] / [[PromiseRejectReactions]]). Каждая реакция связывает тип, ваш обработчик и capability resolve/reject нового Promise, который вернул .then — поэтому сцепление и композируется. .then всегда возвращает новый Promise и никогда не запускает ваш обработчик синхронно: на pending-Promise он добавляет реакции; на урегулированном немедленно ставит микрозадачу PromiseReactionJob. Урегулирование (FulfillPromise/RejectPromise) обходит совпадающий список и ставит по job на реакцию, поэтому fan-out стоит N микрозадач, а цепочка — один тик на звено. Печально известный лишний тик идёт от урегулирования Promise через thenable: движок планирует PromiseResolveThenableJob, чтобы подписаться на внутренний Promise, прежде чем принять его состояние. Отслеживание необработанных отклонений срабатывает, когда у отклонённого Promise нет reject-реакции, но .catch, добавленный позже в том же обороте, снимает флаг. Знание этой машинерии позволяет считать тики точно. Теперь, когда встретишь асинхронный путь, который тормозит без очевидной причины, — считай вслух: сколько звеньев .then, сколько принятий thenable, сколько реакций в fan-out — и будешь знать, куда уходит бюджет микрозадач ещё до профилировщика.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.