Микрозадачи промисов в Node.js: nextTick, queueMicrotask и тайминг отклонений¶
Источник: theNodeBook — Promise Microtasks
Микрозадачи промисов — это задачи V8, которые планируются реакциями на промисы. Суть в порядке выполнения. В top-level коде CommonJS сначала завершается текущий стек JavaScript, затем Node опустошает колбэки process.nextTick(), V8 выполняет микрозадачи промисов, а фазы libuv вроде timers, poll и check дают колбэки позже. У ES-модулей есть одна особенность: оценка top-level модуля уже идёт как часть очереди микрозадач, поэтому микрозадачи, запланированные там, могут выполниться раньше колбэков process.nextTick(), поставленных из того же top-level тела.
Микрозадачи промисов в Node.js¶
Цепочка промисов добавляет в очередь микрозадач ещё задачи. await возобновляется через ту же механику реакций на промисы. Обработка отклонений тоже использует задачи промисов — поэтому отчёт о необработанном отклонении ждёт, пока runtime не увидит, появится ли обработчик вскоре после reject.
Promise.resolve().then() выполняется раньше таймера с нулевой задержкой, потому что обработчик .then() попадает в микроочередь V8. Колбэк таймера идёт через систему таймеров libuv. В top-level CJS Node после опустошения текущего стека сливает nextTick и микрозадачи V8, и только потом event loop переходит к колбэкам таймеров. Один процесс. Разные очереди. Разный приоритет.
process.nextTick() — специфика Node и в современной документации помечен как Legacy. Используйте его, когда нужен приоритет nextTick Node или передача аргументов. Для переносимого планирования микрозадач предпочтительнее queueMicrotask().
Промис — одноразовая машина состояний. Три состояния: pending, fulfilled, rejected. Два перехода: pending → fulfilled, pending → rejected. Один инвариант: после settlement состояние и результат фиксированы.
Модель ECMAScript описывает это через внутренние слоты. [[PromiseState]] — pending, fulfilled или rejected. [[PromiseResult]] хранит значение fulfillment или причину rejection. Реализация держит записи реакций для обработчиков из .then(), .catch() и .finally(). При settlement подходящие записи превращаются в задачи микроочереди, затем очищаются. Однократная диспетчеризация. Второго прохода нет.
Публичный объект почти не отдаёт это состояние напрямую. console.log(promise) в Node может показать <pending> или fulfilled-значение через форматирование инспектора, но в JavaScript нет свойства для чтения [[PromiseState]]. Наблюдение идёт через реакции. Это намеренное ограничение: состояние промиса меняется только по пути settlement, и каждый наблюдатель планируется через ту же систему задач.
Функции resolve и reject из конструктора называют capability промиса. Capability связывает три вещи: объект промиса, функцию fulfillment и функцию rejection. V8 передаёт эту пару по цепочке, чтобы каждое звено .then() могло завершить возвращённый им промис. Поэтому возвращаемое значение обработчика влияет на следующий промис, а не мутирует предыдущий.
1 2 3 4 5 6 | |
Первый resolve(42) переводит промис в fulfilled. Второй resolve(99) возвращается, ничего не меняя. Вызов reject() ведёт себя так же. Resolving-функции конструктора разделяют внутренний флаг already-resolved. Первый вызов его выставляет. Поздние выходят до изменения состояния промиса.
Правило однократного settlement закрывает один из провалов эпохи колбэков из предыдущей подглавы. Колбэк может сработать дважды, если автор API неаккуратен. Capability промиса можно вызвать дважды, но только первый вызов меняет наблюдаемое состояние.
Результат fulfillment — любое нетhenable-значение JavaScript. Примитив. Объект. undefined. Resolve с промисом или thenable следует eventual state этого объекта, а не сохраняет объект как финальное значение. Rejection принимает любое значение, хотя в реальном коде лучше reject с экземплярами Error. Строка даёт сообщение и отбрасывает stack frame, который указал бы место сбоя.
Разрешение промиса защищает и от self-resolution. Если код пытается resolve промис самим собой, промис отклоняется с TypeError. Без этой защиты adoption создал бы цикл, где промис вечно ждёт собственного settlement. Спецификация проверяет этот случай до thenable assimilation.
1 2 3 4 5 6 | |
Такой крайний случай чаще встречается в ручных адаптерах. Его стоит знать: у runtime есть отдельная защита, потому что разрешение промиса по задумке рекурсивно.
Executor выполняется синхронно¶
Функция, переданная в new Promise(), — executor. Конструктор вызывает её сразу, на текущем стеке.
1 2 3 4 5 6 | |
Вывод: before, executor, after. Executor идёт во время конструирования. Если он resolve синхронно, к моменту возврата из конструктора промис уже fulfilled. Обработчик всё равно выполнится позже. .then() всегда планирует задачу реакции, а не вызывает обработчик inline.
Это разделение важно. Конструирование может делать синхронную валидацию, захватывать входы и сразу settle. Наблюдение остаётся асинхронным. Обработчик получает чистый стек после завершения текущего «хода» JavaScript.
Конструктор создаёт capabilities resolve и reject и передаёт их только executor. После конструирования внешний код наблюдает через .then(), .catch() и .finally(). Он может цеплять реакции и читать итоговое значение через обработчики. Settlement capability у него нет, если executor её не «утёк».
Утечь capability можно:
1 2 3 4 5 | |
Такой паттерн иногда называют deferred promise. Он полезен на границах интеграции: ждать одноразовое событие, мостить callback API или подключать тестовый harness. Он же разносит право settlement от кода, создавшего промис. Держите ссылки на resolve и reject в узкой области. Утёкшая capability — изменяемое разделяемое состояние с более приятным синтаксисом.
Если executor бросает исключение, конструктор превращает throw в rejection:
1 2 3 4 | |
Конструктор оборачивает вызов executor. Синхронный throw становится причиной rejection. Так у конструирования промиса появляется чистый канал сбоя при сохранении правила асинхронного планирования обработчиков.
Promise.resolve(value) и Promise.reject(reason) создают уже settled промисы без синтаксиса конструктора. Promise.resolve(42) возвращает fulfilled-промис. Promise.reject(new Error("no")) — rejected. В тестах, адаптерах и мелких ветках библиотек это постоянный инструмент.
У нативных промисов есть быстрый путь:
1 2 3 | |
Promise.resolve() возвращает тот же объект, если вход уже нативный промис того же конструктора. V8 пропускает лишнюю обёртку и лишний hop микрозадачи. Thenable идут по более медленному пути.
Подклассирование меняет fast path. Promise.resolve() проверяет identity конструктора. Промис другого конструктора может обернуться, чтобы возвращённый объект имел запрошенный конструктор. В прикладном коде это редко важно. Авторам библиотек, наследующих Promise, важно: allocation и species-поведение могут удивить пользователей.
Resolve с другим промисом¶
Resolve внешнего промиса другим промисом заставляет внешний adopt eventual state внутреннего.
1 2 3 4 5 6 7 | |
outer остаётся pending, пока inner pending. Когда inner fulfill с "delayed", outer fulfill с "delayed". Если inner reject, outer reject с той же причиной. Итог — adoption состояния, а не fulfillment объектом промиса.
Правило распространяется на thenable: объекты с вызываемым свойством .then. Разрешение промиса читает свойство, проверяет, что оно callable, и планирует работу, которая вызовет его с resolve/reject capabilities внешнего промиса. Нативные промисы, старые библиотеки и кастомные promise-like объекты взаимодействуют через этот протокол.
Свойство читается один раз. Для «странных» объектов это критично. Если геттер .then бросает, промис reject с этой ошибкой. Если геттер вернул не-callable, объект становится значением fulfillment. Если вернул функцию, V8 планирует thenable job с этой ссылкой. Поздние мутации obj.then уже не влияют на запланированную задачу.
1 2 3 4 5 6 | |
Вывод: "from thenable". Процедура разрешения промиса считает объект promise-like и следует результату его .then().
Thenable assimilation стоит дополнительного планирования. Простое значение может fulfill промис сразу, а прикреплённые обработчики всё равно откладываются как reaction jobs. Thenable проходит PromiseResolveThenableJob, который вызывает .then() позже из микроочереди. Когда thenable вызывает переданную fulfillment capability, V8 планирует обычные PromiseReactionJob для обработчиков. В точных тестах порядка виден лишний виток микрозадачи.
Проверка утиная: объект и callable .then — достаточно.
Это бьёт по данным с случайным методом then. Документ БД, ответ API или mock с callable then при передаче в resolve() трактуется как thenable. Процедура разрешения вызывает его. Если метод бросает — внешний промис reject. Если не вызывает ни один колбэк — внешний остаётся pending.
Практичный фикс: обернуть значение, переименовать свойство или вернуть его из обработчика, который уже идёт после точки assimilation. Чаще всего это в интеграционном коде, где нетипизированные внешние данные пересекают границу промиса.
Thenable могут быть кривыми или враждебными. .then() может дважды вызвать fulfillment, reject после fulfillment, бросить после успешного fulfillment или вызывать оба колбэка в разных ходах. Процедура разрешения создаёт resolving-функции с внутренним флагом already-resolved. Побеждает первый вызов. Поздние возвращаются. Throw после первого успешного колбэка игнорируется — внешний промис уже settled.
1 2 3 4 5 6 7 | |
Вывод: "ok". Попытка reject приходит после fulfillment и ничего не меняет. Флаг already-resolved отделён от [[PromiseState]]; он защищает capability во время adoption, пока внешний промис ещё не в финальном состоянии.
Adoption — одна из причин, почему код промисов выглядит синхронно, но выполняется позже. Thenable может вызвать resolve() inline из .then(). Внешний промис всё равно планирует реакции через микрозадачи. Получается стабильное правило даже при странном чужом объекте: выполнение обработчиков ждёт checkpoint.
Цепочки¶
.then() принимает два необязательных обработчика: onFulfilled и onRejected. Каждый раз возвращает новый промис.
1 2 3 4 | |
Три .then() — три промиса. Каждый обработчик получает предыдущее settled-значение. Обычный return fulfill следующий промис этим значением. Throw — reject. Return промиса заставляет следующий adopt состояние возвращённого промиса.
Форму цепочки задаёт возвращаемый промис. Каждое звено владеет следующим. Каждый обработчик превращает успех или сбой в следующее состояние. Отступы остаются плоскими, runtime даёт exactly-once settlement на каждом звене.
Пропущенный return всё равно больно:
1 2 3 4 5 | |
Первый обработчик возвращает undefined, следующий промис fulfill с undefined. У V8 нет мнения о намерении. Линтеры ловят много такого. Runtime молчит.
Throw становятся rejection:
1 2 3 4 5 6 | |
У второго .then() только fulfillment-обработчик, rejection проходит дальше. .catch(fn) — это .then(undefined, fn). Он цепляет rejection-обработчик к промису, который вернуло предыдущее звено.
Позиция меняет поведение. .catch() в конце ловит сбои со всех предыдущих звеньев. .catch() посередине может восстановить цепочку и отдать обычное значение дальше.
1 2 3 | |
Обработчик catch возвращает строку. Возвращённый промис fulfill этой строкой, следующий .then() её получает. Повторный throw из catch оставляет цепочку на пути rejection.
.finally(fn) выполняется при любом исходе. Значения не получает. Пропускает исходное значение или причину, если сам не бросает и не возвращает rejected-промис.
1 2 3 | |
Используйте для cleanup, который должен увидеть завершение, не меняя результат: закрыть handle, сбросить таймер, отпустить lock, уменьшить счётчик in-flight.
У двухаргументной формы .then() есть свой провал. В .then(onFulfilled, onRejected) rejection-обработчик ловит reject предыдущего промиса. Он не ловит throw из onFulfilled в том же вызове. Цепной .catch() вешается на промис, который вернул .then(), и ловит throw из fulfillment-обработчика. Для большинства кода понятнее .catch() в конце цепочки.
Пустые .then() создают pass-through промисы. promise.then() следует за исходным промисом и добавляет allocation. После рефакторинга такое всплывает — удаляйте.
Ещё одна деталь для отладки: обработчики цепляются к промису слева. В a.then(f).catch(g) функция g обрабатывает reject от a и throw из f, потому что висит на промисе, возвращённом then. В a.then(f, g) функция g обрабатывает только reject от a. Промис из этого вызова получает то, что произведут f или g. Тот же API. Другая точка крепления.
Несколько обработчиков на одном промисе ведут себя иначе, чем цепочка. Все видят одно settled-значение, каждый .then() возвращает свой следующий промис.
1 2 3 4 | |
Вывод: 11, 12, 13. Три реакции на p. При settlement V8 ставит три reaction job в порядке регистрации. Return первого обработчика питает только промис первого .then(). На второй и третий он не влияет — они тоже на p.
Сравните с цепочкой:
1 2 3 4 | |
Вывод: 16. Каждый обработчик на промисе от предыдущего .then(). Значение идёт звено за звеном. Это объясняет путаные рефакторинги: разбить цепочку на отдельные .then() на исходном промисе меняет поток данных при том же видимом API.
То же для ошибок. Три .catch() на одном rejected-промисе видят один reject. Три catch в цепочке — последовательность восстановления, где return или throw каждого решает следующее звено. Размещение обработчиков — это граф управления.
Как V8 выполняет обработчики промисов¶
Обработчик никогда не вызывается изнутри .then().
Если промис pending, .then() кладёт реакцию во внутренний список. Если уже settled, .then() сразу создаёт reaction job. В любом случае обработчик идёт из микроочереди V8 на более позднем checkpoint.
V8 выполняет микрозадачи через очередь, связанную с активным контекстом. Реакции промисов и колбэки queueMicrotask() — туда. Таймеры libuv, I/O и setImmediate() — в структурах libuv. process.nextTick() — в очереди Node на стороне JavaScript. В top-level CJS и обычных checkpoint из нативных колбэков Node сливает nextTick до микрозадач V8.
Очередь опустошается до конца. Если микрозадача ставит ещё одну, новая выполняется в том же drain до возврата к фазам event loop. Этот рекурсивный drain — источник и гарантии порядка, и режима starvation.
Конкретная задача промиса — PromiseReactionJob. При settlement V8 обходит зависимые реакции и ставит по одной задаче на реакцию. Задача делает три вещи: вызывает нужный обработчик с settled-значением, смотрит на результат или исключение, settle промис, который вернул .then().
Внутри реакция несёт обработчик, тип реакции и capability для следующего промиса в цепочке. Capability — пара resolve/reject. При нормальном return V8 вызывает resolve следующего промиса этим значением. При throw — reject с брошенным значением. Если обработчика нет для текущего типа settlement, V8 делает pass-through: fulfillment → resolve, rejection → reject.
V8 хранит реакции компактно: promise-heavy программы создают их много. Pending-промис держит внутренние записи. При settlement V8 сохраняет порядок регистрации, создаёт задачи и очищает слоты реакций, чтобы обработчики можно было собрать после выполнения. Объект промиса хранит финальный результат. Записи реакций исчезают.
Для fulfilled-промиса отсутствующий fulfillment-обработчик — identity continuation: то же значение в следующий промис. Для rejected — thrower continuation: та же причина в reject capability следующего. V8 может представить эти пути без пользовательских функций. Поведение видно только downstream-обработчикам.
Микрозадачи выполняются, когда V8 уже внутри isolate и context. Node решает, когда начать drain. После запроса checkpoint V8 крутит микрозадачи, пока очередь не пуста. Задачи промисов, созданные в drain, попадают в ту же очередь. То же для queueMicrotask(). Нативные колбэки, таймеры и I/O ждут снаружи drain.
1 2 3 4 5 | |
Вывод в CommonJS: 1, 5, 4, 3, 2.
Сначала синхронный код. setTimeout() регистрирует таймер libuv. Promise.resolve().then() ставит PromiseReactionJob в V8. process.nextTick() добавляет в очередь nextTick. Последний console.log() — до любого колбэка.
Стек пуст. Node входит в checkpoint. Сначала nextTick — печатается 4. Затем микрозадачи V8 — 3. После опустошения очередей event loop продвигается. Фаза timers проверяет истёкшие таймеры — 2.
Практический порядок в CommonJS: синхронный JavaScript, очередь nextTick, микроочередь V8, фазы event loop. Node повторяет checkpoint после колбэков, которые вошли в JavaScript из нативного кода. Колбэк таймера может планировать промисы. I/O-колбэк — nextTick. Node сливает высокоприоритетные очереди перед продолжением.
Граница C++ важна: Node выставляет MicrotasksPolicy в kExplicit, и V8 ждёт явного checkpoint от embedder. Node делает это, потому что у него своя очередь nextTick и учёт rejection, которые нужно встроить в тот же checkpoint. Автоматический browser-style checkpoint дал бы меньше контроля над порядком.
При dispatch из нативного кода в JavaScript Node создаёт InternalCallbackScope. Когда колбэк возвращается, InternalCallbackScope::Close() запускает путь checkpoint. На слое JavaScript processTicksAndRejections в lib/internal/process/task_queues.js сливает nextTick, вызывает binding для микрозадач V8 и ведёт учёт rejection промисов. Если nextTick или микрозадачи V8 создали ещё работу, цикл повторяется, пока обе очереди не пусты.
Нативная точка входа использует тот же механизм колбэков, что в главе про колбэки. Завершение libuv доходит до C++ binding Node. Node входит в нужный контекст V8, вызывает JavaScript, закрывает scope колбэка. При закрытии координируются async hooks after, drain nextTick, drain микрозадач и отчёт о rejection. Поэтому промис, запланированный внутри колбэка fs.readFile(), может выполниться раньше следующего доставленного I/O-колбэка.
Node также запускает checkpoint после начальной оценки скрипта и вокруг других точек входа embedder в JavaScript. В браузерных описаниях микрозадачи часто «после каждой task». Правило Node специфично для embedder: checkpoint выбираются так, чтобы сохранить приоритет nextTick и совпасть с dispatch нативных колбэков. ES-модули меняют top-level порядок, потому что оценка модуля сама идёт как микрозадача. Система принадлежит Node плюс V8, а не одному libuv.
Деталь на память: nextTick приоритетен на границах checkpoint, но не прерывает активный drain микрозадач V8. Если обработчик .then() вызывает process.nextTick(), этот колбэк ждёт, пока V8 не закончит текущую микроочередь. Затем Node снова крутит nextTick. Правило приоритета действует между drain, а не внутри них.
Порядок создания очередей меняется, когда очереди планируют друг друга:
1 2 3 4 | |
Колбэк queueMicrotask() выполнится раньше process.nextTick(), созданного внутри обработчика промиса. V8 уже drain микрозадачи, новая микрозадача попадает в активный drain. Колбэк nextTick ждёт возврата управления в цикл checkpoint Node. После завершения V8 Node видит очередь nextTick и сливает её.
Вывод: micro, tick.
Поменяйте место создания — порядок другой:
1 2 3 4 | |
Вложенный nextTick выполнится раньше обработчика промиса. Node drain очередь nextTick, новые nextTick остаются в ней, пока она не опустеет. Затем — микрозадачи V8. Тот же checkpoint. Другая активная очередь.
Вывод: tick, promise.
Используйте это знанием для отладки, а не как стиль кода. Зависимость от вложенного порядка nextTick против промиса трудно ревьюить. Реальная ценность — когда log на ход раньше ожидаемого: обычно виноваты правила активной очереди.
queueMicrotask(fn) планирует напрямую в микроочередь V8 без allocation промиса для самой операции планирования.
1 2 3 | |
Вывод в CommonJS: nextTick, microtask, promise. queueMicrotask() и реакции промисов делят FIFO внутри очереди V8. Очередь nextTick на checkpoint идёт раньше обеих.
Top-level ES-модуль может дать другой порядок:
1 2 3 | |
Вывод в ESM: microtask, promise, nextTick. Node уже оценивает модуль из микроочереди, поэтому промис и queueMicrotask() попадают в активный drain. Колбэк nextTick ждёт возврата управления в Node.
Starvation¶
Полное опустошение имеет цену. Самовосполняющаяся микроочередь не даёт event loop дойти до timers, poll, check и close.
1 2 3 4 5 6 7 | |
Каждый обработчик ставит следующий, пока счётчик не обнулится. Таймер печатается только после этой цепочки. Уберите границу — checkpoint крутит только JavaScript. Таймеры висят pending. Завершения I/O стоят за checkpoint. Процесс жрёт CPU без прогресса event loop.
process.nextTick() может сделать то же самое; исторически в проде это болело сильнее, потому что nextTick использовали как примитив «уступить». Он уступает со стека, но остаётся впереди фаз event loop.
V8 и Node не ограничивают глубину микрозадач за вас. Нет фиксированного cap, который спасёт рекурсивную цепочку. Ограниченная рекурсия нормальна. Неограниченная — starvation цикла.
Когда большой CPU-лёгкий batch должен дать ход I/O и таймерам между кусками, используйте setImmediate(). Он идёт в фазу check, и между batch цикл успевает сделать виток фаз. Микрозадачи выполняются внутри checkpoint и должны опустеться, прежде чем продолжится работа фаз.
Компромисс — overhead против latency. Микрозадачи дешёвые и плотно упорядочены. setImmediate() дороже и даёт другой работе шанс на планирование. В коде, обслуживающем запросы, обычно важнее ограниченная latency чужих сокетов, чем прогнать один batch через цепочку микрозадач.
Размер batch — ручка управления. Обработайте несколько сотен элементов, следующий кусок через setImmediate(), и latency на запрос останется ограниченной. Весь batch через рекурсию промисов — и каждый сокет, ставший readable во время batch, ждёт за вашей микроцепочкой. Правильное число зависит от стоимости элемента и бюджета latency — меряйте под нагрузкой, а не копируйте константу наугад.
Запуск работы и наблюдение результата¶
API проще проектировать, когда разделить «начать работу» и «наблюдать результат». Конструирование промиса или вход в функцию запускает работу. .then() наблюдает завершение. Это разные акты, и они могут происходить в разное время.
1 2 3 | |
readConfig() решает, когда начнётся чтение файла, lookup в кэше или сетевой вызов. Поздний .then() только регистрирует реакцию. Если к этому моменту промис уже fulfilled, реакция всё равно идёт через микроочередь. Если pending — сидит во внутреннем списке до settlement.
Это влияет на ленивые API. Промис обычно представляет уже начатую работу. Функция, возвращающая промис, может быть ленивой: работа стартует при вызове. Передача промиса — передача in-flight или уже settled операции. Передача функции — право начать позже.
1 2 | |
eager начинает сразу. lazy — при вызове. Правила планирования после settlement те же, но меняется timing ресурсов: сокеты открываются раньше, таймеры стартуют раньше, учёт unhandled rejection может начаться до того, как потребитель повесил обработчики. Поэтому библиотеки часто принимают функции для retry, лимитеров параллелизма и отложенных batch — им нужно контролировать момент старта каждой promise-producing операции.
Обработчики промисов также выполняются после синхронного cleanup в текущем ходе. Это может быть полезно и неожиданно.
1 2 3 4 | |
Обработчик печатает true. Присваивание успело до checkpoint микрозадач. Если нужно старое значение — захватите в локальную переменную до планирования обработчика. Микрозадачи сохраняют порядок; они не «замораживают» ваши переменные.
Маленькая договорённость для ревью: функции, возвращающие промис, называйте глаголами; значения промисов — как результат в процессе. loadUser() запускает работу. userPromise — наблюдаемый результат уже начатой работы. Имена не enforce timing, но подсвечивают границу, где чаще ошибаются. Retry-helper должен получать () => loadUser() — нужна свежая попытка каждый раз. Рендерер может получить userPromise — ему нужно только наблюдать. Тот же механизм промисов. Разное владение моментом старта.
Эта граница небольшая, но отделяет предсказуемое планирование от случайного раннего выполнения в коде, где смешаны кэши, I/O и setup при старте, retry и fan-out запросов под нагрузкой.
Ошибки и отклонения¶
Rejection распространяется, пока его не обработает rejection-обработчик.
1 2 3 4 | |
Обработчики только fulfillment обходятся. Значение rejection идёт через pass-through реакции, пока .catch() его не примет. Внутри .catch() обычный return восстанавливает цепочку, throw оставляет rejected, return rejected-промиса — тоже rejected.
Логирование с повторным throw — типичная форма, когда один слой хочет наблюдаемость, а другой владеет ответом:
1 2 3 4 5 6 7 | |
Первый catch логирует и бросает. Второй видит тот же сбой. Если первый catch вернулся нормально, цепочка продолжилась бы с этим значением — часто undefined.
throw в обработчике и return Promise.reject(error) оба reject промис, который вернул .then() этого обработчика. В синхронных телах я предпочитаю throw — намерение прямое, stack чище. Promise.reject() уместен в expression-heavy коде или хелперах, которые уже возвращают промис.
1 2 3 4 | |
То же поведение, что return Promise.reject(new Error("inactive")), с более коротким синтаксисом.
Необработанные отклонения¶
Node сообщает о rejected-промисах без rejection-обработчика к моменту своей проверки. В Node v24 режим --unhandled-rejections по умолчанию — throw: эмитится unhandledRejection, и если на событие нет слушателя, rejection поднимается как uncaught exception. Флаг также принимает strict, warn, warn-with-error-code и none, но в проде unhandled rejection стоит считать багом уровня процесса.
Путь обнаружения начинается в V8. Когда промис reject без обработчика, V8 вызывает hook rejection Node. Node записывает промис и откладывает отчёт, чтобы текущий ход успел повесить обработчик. Если обработчик появился вовремя, Node фиксирует handled transition вместо unhandled. Если обработчика всё ещё нет — unhandledRejection на process, и в режиме throw по умолчанию это uncaught exception без слушателя на событии.
Задержка небольшая, но она есть: мгновенный отчёт пометил бы валидные паттерны. Промис может reject, а .catch() повесить позже в том же синхронном ходе. Цепочка может подключить обработку через более поздний .then() до завершения checkpoint. Node даёт коду шанс подключить обработчик до отчёта.
Два события process описывают жизненный цикл. unhandledRejection — когда Node решил, что обработчика не было вовремя. rejectionHandled — если обработчик появился после отчёта. Второе событие — диагностическая уборка, не откат первого. В режиме throw процесс может уже идти в uncaught-exception handling.
Задержку можно наблюдать:
1 2 3 4 5 6 7 8 9 10 11 | |
Вывод: reported: oops, caught: oops, handled later. Колбэк setTimeout() идёт после checkpoint микрозадач и границы планирования таймера. Node сообщает об отклонении до того, как отложенный catch прикрепится. Позже catch вызывает rejectionHandled, но исходный отчёт уже был. Без слушателя unhandledRejection режим throw поднимет rejection как uncaught exception до того, как таймер успеет повесить catch.
Вешайте обработчики rejection в той же цепочке, где создаёте работу. Для намеренно оторванного промиса завершайте .catch(). В async функциях — try/catch вокруг await или верните промис вызывающему, который его обработает.
promise.catch(() => {}) считается обработкой и прячет сбой. Иногда так и задумано для best-effort телеметрии или записи в кэш. Решение должно быть явным в коде. Логируйте достаточно контекста для отладки, если сбой не намеренно silent.
Библиотечному коду не стоит ставить глобальную политику process.on("unhandledRejection"). Это решает приложение. Библиотека может возвращать промисы, документировать причины rejection и вешать внутренние catch на свой оторванный фон. Политика rejection на уровне процесса — рядом с сигналами и exit handling в точке входа.
util.promisify() и callback API¶
В Node ещё много API в форме колбэков, и пакеты часто отдают только error-first колбэки. util.promisify(fn) оборачивает такую функцию и возвращает promise-returning функцию.
1 2 3 4 5 6 7 | |
Обёртка вызывает исходную функцию с вашими аргументами и дописывает сгенерированный колбэк. Truthy err — reject промиса. Иначе resolve с успешным значением.
Каждый вызов обёртки аллоцирует промис и замыкание колбэка. Для одного чтения файла это незаметно. В горячих путях библиотек эти allocation видны в heap profile.
Важен и binding. promisify() вызывает исходную функцию как обычную, если вы сами не сделали bind. Методы, зависящие от this, нужно bind до обёртки.
1 2 | |
У core-функций обычно всё состояние в аргументах или внутренних binding — проблемы реже. User-space классы часто на this. Promisified метод без bind может упасть ещё до async-работы.
Некоторые Node API отдают в колбэке несколько успешных значений. fs.read() получает (err, bytesRead, buffer). В core есть внутренние метаданные, чтобы promisified-версии resolve объектом вроде { bytesRead, buffer }, а не отбрасывали всё после первого значения. Свои API могут определить util.promisify.custom по той же причине.
1 2 3 4 5 6 7 8 9 | |
Предпочитайте promise-native API Node, где они есть. require("node:fs/promises") использует promise-oriented binding для многих файловых операций: внутренний fs binding в promise mode или методы FileHandle, а не общий JS-обёртчик вокруг callback API.
util.promisify() остаётся полезным для сторонних callback API и старых внутренних модулей. Это адаптер на границе. Как только данные в форме промиса — держите остальной код в одном async-стиле.
То же в обратную сторону: если публичный callback API нужен для совместимости, адаптер колбэка держите тонким и ведите в promise-native реализацию. Смешанные внутренние стили дублируют обработку ошибок и усложняют рассуждение о порядке.
util.callbackify() адаптирует в другую сторону: вызывает promise-returning функцию, вешает обработчики и ведёт fulfillment или rejection в error-first колбэк. Добавляется та же граница планирования reaction промиса. Потребители колбэков, ожидающие точный timing, могут увидеть лишний виток микрозадачи.
У callbackify() странный случай: falsy rejection оборачивается, потому что error-first колбэк сигнализирует сбой truthy первым аргументом. Reject с null или undefined даёт сгенерированный Error с исходной причиной внутри. Ещё один аргумент reject с настоящими Error.
Модель стоимости¶
Каждый .then() аллоцирует промис. Каждый settled промис с зависимым обработчиком планирует PromiseReactionJob. Каждое замыкание обработчика может удерживать внешние переменные, пока цепочка их не отпустит.
Стоимость — churn allocation, удержанные замыкания и метаданные async stack.
Обработчики промисов всегда асинхронны. Даже Promise.resolve(42).then(fn) планирует fn в микрозадаче. Поведение «всегда async» — из контракта промиса и убирает смешанное sync/async поведение колбэков из предыдущей подглавы. Кэшированный результат и I/O используют одинаковый timing наблюдения: сначала текущий стек, затем микрозадачи.
Длинные цепочки создают короткоживущие объекты. Десять .then() — десять промежуточных промисов и десять reaction jobs. Young-generation collector V8 с этим хорошо справляется, особенно в Node v24, но churn виден в коде, который строит огромное число мелких цепочек в секунду.
Годы оптимизаций V8 сжимали путь промисов: fast path для уже settled нативных промисов, оптимизированные built-in для .then() и .finally(), лучший async stack, оптимизации await в async functions. Всё равно чисто callback внутренний путь может аллоцировать меньше promise-heavy. Драйверы БД, парсеры, планировщики и транспорты иногда отдают промисы снаружи, а внутри — колбэки или request objects.
В большинстве прикладного кода промисы оставляйте. Читаемость и единый канал ошибок обычно важнее дельты allocation. Профилируйте, прежде чем менять цепочку на колбэки. Если allocation промисов видна в профиле — young-generation churn, частый minor GC и кадры PromiseReactionJob в CPU profile.
Легко пропустить удержание памяти. Цепочка a.then(f1).then(f2).then(f3) создаёт промежуточные промисы, которые могут быстро умереть, когда следующее звено settle. Но замыкание обработчика удерживает всё захваченное из внешней области. .finally(), закрывающий большой buffer, держит buffer, пока finally не выполнится и возвращённый промис не settle. Ссылка на промис из середины цепочки может держать связанное состояние дольше ожидаемого.
Типичный баг — случайный capture:
1 2 3 4 5 | |
Замыкание finally держит big, пока цепочка не settle. Если big — буфер тела запроса и операция ждёт удалённый I/O, давление на память растёт с параллелизмом. Извлеките маленькое нужное значение до создания замыкания.
1 2 3 4 5 6 | |
То же поведение. Меньше удержанной памяти.
Компромисс отладки тоже реален. В современном Node async stack traces включены по умолчанию; V8 хранит дополнительные метаданные continuation, чтобы rejected-цепочки показывали полезные async frames. У метаданных есть цена памяти. Я оставляю их включёнными ради отладки, пока измеренная нагрузка не скажет иначе.
Оптимизация начинается с формы, а не синтаксиса. Цепочка, сериализующая независимые операции, стоит дороже самого механизма промисов. Оторванный промис без обработчика — неоднозначность сбоя. Плотный цикл с миллионами промисов — давление на аллокатор. Сначала чините это. Потом смотрите, окупается ли более низкоуровневый callback path своей сложностью.
На практике в проде я бы делал так:
- Promise-native API Node вместо promisify core API.
.catch()в цепочке, которая создаёт оторванную работу..catch()в конце цепочки вместо двухаргументного.then()в большинстве кода.throwв обработчиках, если хелпер уже не возвращает промис.setImmediate()между крупными независимыми batch, которым нужно дать ход I/O.- Heap profile на удержанные замыкания вокруг больших буферов и request objects.
- Hotspot
PromiseReactionJob— данные профилирования, а не повод заранее переписывать чистый код.
Связанное чтение¶
- Предыдущая: Error-first колбэки в Node.js
- Далее: Async/await в Node.js