Перейти к содержанию

Async/await в Node.js: приостановка и микрозадачи

Источник: theNodeBook — Async/Await in Node.js

Async-функции в Node.js всегда возвращают промисы. За синтаксисом стоят обёртка в promise, приостановка и планирование. await вычисляет выражение, прогоняет его через promise resolution, приостанавливает async-функцию и возобновляет её позже из job реакции на промис. Состояние async-функции хранит V8. Node решает, где выполнять promise jobs относительно process.nextTick(), таймеров, I/O-колбэков и остальной работы event loop.

Как работает async/await в Node.js

Синтаксис скрывает цепочку промисов. Код после await выполняется на следующем шаге async-функции; выброшенные ошибки превращаются в rejection промиса, если их не перехватить локально. После приостановки async-функции окружающий JavaScript-стек продолжает работу — порядок меняется, как только на пути появляется await.

async меняет контракт функции в момент, когда вы его пишете.

Вызовите её — получите промис. Каждый раз. Тело может вернуть 42, выбросить ошибку, трижды дождаться сети или просто закончиться без return. Вызывающая сторона всё равно получает промис: V8 входит в machinery async-функции ещё до первой строки вашего кода.

Отсюда скучные баги. Async-колбэк, переданный в Array.map(), даёт массив промисов. Async-компаратор в Array.sort() отдаёт sort() промис там, где ожидается число. Плохое поведение сидит на границе между синхронным API и функцией, возвращающей промис.

Маленькое ключевое слово. Большая смена контракта.

1
2
3
4
5
6
async function getNumber() {
    return 42;
}

const result = getNumber();
console.log(result instanceof Promise);

В лог попадёт true. Возвращённое 42 станет fulfillment value промиса из getNumber(). Отсутствующий return выполняет промис с undefined. throw отклоняет возвращённый промис.

1
2
3
4
5
async function boom() {
    throw new Error('broken');
}

boom().catch((e) => console.log(e.message));

Исключение возникает во время вызова boom(). Обёртка async-функции перехватывает его и отклоняет внешний промис. Вызывающий получает ошибку через промис, который вернула boom().

Возврат промиса заставляет внешний промис принять состояние возвращённого. Возврат thenable идёт через thenable assimilation — это разобрано в предыдущей подглаве. Путь с нативным промисом оптимизирован в современном V8, включая V8 в Node v24, но наблюдаемое правило стабильно: вызывающий видит один промис, который завершается возвращённым значением, rejection или принятым состоянием.

Одна деталь return постоянно окупается. return somePromise пробрасывает промис через путь разрешения async-функции. return await somePromise наблюдает somePromise в точке await, затем разрешает внешний промис выполненным значением. В современном V8 для нативных промисов на await-сайтах есть быстрый путь. Используйте return await внутри try/catch и finally, когда важно качество стека. Для простого проброса достаточно прямого return.

await приостанавливает одну функцию

await ставит на паузу текущую async-функцию. Процесс при этом продолжает работать.

1
2
3
4
5
6
7
8
9
async function example() {
    console.log('A');
    const val = await Promise.resolve('B');
    console.log(val);
    console.log('C');
}

example();
console.log('D');

Вывод: A, D, B, C.

Первый лог выполняется на текущем стеке вызовов. V8 доходит до await, записывает состояние функции, вешает реакцию на ожидаемый промис и возвращает управление вызывающему. Вызывающий печатает D. Позже, на checkpoint микрозадач, V8 возобновляет async-функцию с выполненным значением "B" и выполняет оставшиеся логи.

Код до первого await — синхронный. Полностью синхронный. Async-функция без ни одного await в теле отрабатывает целиком до возврата вызывающему, хотя результат всё равно доставляется через промис.

1
2
3
4
5
6
7
async function noAwait() {
    console.log('sync');
    return 'done';
}

noAwait();
console.log('after');

Вывод: sync, after. .then(), повешенный на возвращённый промис, выполнится позже как микрозадача — promise handlers сохраняют правило планирования из предыдущей подглавы.

Обычные значения тоже можно await-ить.

1
2
3
4
async function waitValue() {
    const n = await 42;
    console.log(n);
}

V8 запускает promise resolution для значения, приостанавливает функцию, затем возобновляет её в микрозадаче с 42. Универсальный код использует это свойство, когда вход может быть и значением, и промисом. Рукописный await 42 в основном добавляет лишний шум планирования.

Ожидание уже выполненного промиса всё равно приостанавливает. То же для await 42. V8 сократил число ходов микрозадач на типичном пути с нативным промисом, сохранив границу планирования. Код после await выполняется после того, как текущий синхронный стек опустеет.

Последовательные await создают последовательную работу:

1
2
3
4
5
async function three() {
    const a = await step1();
    const b = await step2(a);
    return step3(b);
}

step2 стартует после fulfillment step1. step3 — после fulfillment step2. Суммарная задержка — сумма ожиданий плюс накладные расходы планировщика. Такая форма подходит при реальных зависимостях данных. Она теряет время, когда операции независимы.

Форма цепочки промисов

Async/await читается линейно. Наблюдаемое поведение совпадает с цепочкой промисов.

1
2
3
4
5
async function fetchData(url) {
    const response = await fetch(url);
    const json = await response.json();
    return json;
}

По смыслу это та же форма продолжений, что и здесь:

1
2
3
4
5
function fetchData(url) {
    return fetch(url)
        .then((response) => response.json())
        .then((json) => json);
}

Каждый await создаёт продолжение. Код после await выполняется, когда ожидаемый промис завершился. Финальный return разрешает промис, который async-функция отдала вызывающему.

Путь ошибок тоже ложится аккуратно.

1
2
3
4
5
6
7
8
9
async function fetchSafe(url) {
    try {
        const resp = await fetch(url);
        return await resp.json();
    } catch (e) {
        console.error('failed:', e.message);
        return null;
    }
}

Rejection от любой из awaited-операций превращается в throw в соответствующей точке await. Блок catch его получает. Вы получаете прямолинейный control flow с планированием промисов под капотом.

Практическая выгода — лексическая область видимости. Цепочка .then() режет код на отдельные функции. Чтобы делиться локальным состоянием между шагами, приходится возвращать составные объекты, замыкать переменные или плодить функции. Async-функция держит одну лексическую область через точки приостановки. response остаётся доступным после следующих await, потому что V8 сохраняет приостановленный execution context в куче и восстанавливает его позже.

Удобство имеет цену по памяти. Держите в локальной переменной буфер на 50 МБ, дойдите до await db.save() — буфер остаётся достижимым, пока висит промис базы. Объект async-функции удерживает свою область. V8 может собрать объекты только после того, как живые переменные перестают на них ссылаться.

Связь с генераторами историческая и в основном полезна при чтении старого кода. До появления async/await в ES2017 многие Node-проекты использовали generator functions с runner-библиотеками. Runner вызывал .next() после fulfillment каждого yield-нутого промиса и .throw() после rejection. Async-функции превратили этот паттерн в синтаксис со встроенной в движок интеграцией промисов. V8 по-прежнему разделяет часть machinery приостановки с генераторами, включая концепции suspend/resume в bytecode, тогда как promise resolution и обработка внешнего промиса специфичны для async-функций.

State machine V8

Раздел про внутренности. Практическое правило короткое: каждый await — точка приостановки. Machinery за ним — откуда берётся модель стоимости.

Когда V8 компилирует async-функцию, он генерирует bytecode, который можно приостановить и возобновить. При вызове V8 создаёт промис для вызывающего и внутренний объект async-функции для отслеживания состояния выполнения. В реализации V8 это JSAsyncFunctionObject, связанный с machinery генераторов. В нём хранятся внешний промис, текущее состояние продолжения и ссылки, нужные для возобновления после settlement ожидаемого промиса.

Внешний промис выделяется при входе в функцию. Вызывающий получает его даже если тело долго работает до первого await. При return V8 разрешает внешний промис. При throw без локальной обработки V8 отклоняет внешний промис. Пять await — один и тот же внешний промис остаётся pending через все пять приостановок.

Первая часть функции выполняется как обычный JavaScript. Локальные переменные живут в активном stack frame, пока выполнение идёт. На await V8 вычисляет выражение, запускает promise resolution для значения и вешает fulfillment/rejection reactions на получившийся промис. Затем сохраняет текущий execution context: локальные переменные, состояние операндов и позицию в bytecode. Активный stack frame может развернуться. Сохранённое состояние живёт в куче.

Функция на паузе.

Когда ожидаемый промис завершается, V8 ставит в очередь микрозадач PromiseReactionJob. Node v24 использует явные checkpoints микрозадач, как в предыдущей подглаве: сначала дренируется process.nextTick(), затем V8 дренирует promise microtasks. Когда reaction job для await выполняется, V8 восстанавливает сохранённый контекст async-функции и продолжает с bytecode сразу после выражения await.

Fulfillment возобновляет выполнение со значением. Rejection — как throw. Поэтому работает такой код:

1
2
3
4
5
6
try {
    const user = await fetchUser(id);
    return user.name;
} catch (e) {
    return null;
}

V8 возобновляет функцию внутри исходного try. Если промис отклонён, возобновлённый bytecode бросает причину rejection в точке await, и catch получает её обычным путём исключений. JavaScript использует здесь ту же exception machinery.

Старые версии V8 платили больше за каждый await. До V8 7.2 ожидание уже выполненного нативного промиса тянуло лишние аллокации промисов и лишние ходы микрозадач. Команда V8 изменила и реализацию, и spec path для случая нативного промиса, сократив типичный путь await до одного хода микрозадачи. Node 12 подхватил эту работу. Node v24 несёт более поздний оптимизированный путь и последующие улучшения вокруг promise resolution, async stack traces и аллокаций.

Быстрый путь относится к нативным промисам текущего realm. Thenable идут по полному пути assimilation: движок должен прочитать и вызвать свойство .then. Оно может выполнить пользовательский код. Может бросить. Может разрешиться позже. V8 соблюдает этот протокол.

В layout объекта есть несколько полей, которые стоит назвать. Объект async-функции несёт внешний промис и resume closures для fulfilled/rejected await. V8 может переиспользовать эти closures между await в одной функции — меньше аллокаций обработчиков на повторяющихся await. Сами промисы по-прежнему несут списки реакций. Планирование микрозадач принадлежит promise reactions, а объект async-функции хранит состояние приостановленного выполнения.

Аллокация обычно начинается в young generation кучи V8. Короткие async-функции заканчиваются там и дёшево умирают. Функция, приостановленная на медленном сетевом вызове, может пережить young GC и переехать в old generation. Сам объект мал. Важнее удерживаемые локалы. Приостановленный handler с разобранным телом запроса, большим Buffer и замыканием на объект запроса может держать в памяти гораздо больше, чем сама async machinery.

Async stack traces добавляют ещё один слой. Обычный стек покрывает текущий синхронный call stack. Границы await режут выполнение между ходами микрозадач, поэтому V8 хранит метаданные для восстановления цепочки async-вызовов. При throw современный Node показывает кадры вроде:

1
2
3
4
Error: oops
    at innerFn (file.js:12:11)
    at async middleFn (file.js:8:20)
    at async outerFn (file.js:3:18)

Кадры с async отмечают границы await. Метаданные превращают отклонённый async-поток в stack trace с логическими вызывающими — это то, что нужно, когда внутренняя функция пишет только readFromReplica или parseConfig.

Одна низкоуровневая деталь объясняет многие баги порядка. await возобновляется через PromiseReactionJob — тот же тип job, что в главе про промисы. Продолжения await делят порядок с обработчиками .then() и queueMicrotask(). Очередь nextTick Node по-прежнему имеет приоритет на каждом checkpoint. Это разделение объясняет большинство случаев «почему этот лог напечатался первым» в коде с async/await.

Правила порядка, которые вы реально видите

Код после await выполняется как микрозадача.

1
2
3
4
5
6
7
8
console.log('1');
async function run() {
    console.log('2');
    await Promise.resolve();
    console.log('3');
}
run();
console.log('4');

Вывод: 1, 2, 4, 3. Сначала синхронный код. Продолжение после await — когда опустошается очередь микрозадач.

Несколько async-функций чередуются в порядке постановки их продолжений в очередь микрозадач.

1
2
3
4
5
6
async function a() {
    console.log('a1');
    await Promise.resolve();
    console.log('a2');
}
a();

Добавьте вторую функцию с той же структурой и вызовите a(); b();. Вывод: a1, b1, a2, b2. Оба синхронных префикса выполняются сразу. Затем продолжения после await дренируются FIFO.

Лишние await добавляют лишние ходы. Если x() печатает до двух await, а y() — до одного, вызов x(); y(); может дать x1, y1, x2, y2, x3. x возобновляется, печатает x2, снова попадает на await и уходит в конец очереди микрозадач. y возобновляется раньше второго продолжения x.

Специфичный для Node порядок сохраняется. process.nextTick() выполняется до promise microtasks V8 на каждом checkpoint.

1
2
3
4
5
6
7
async function run() {
    await Promise.resolve();
    console.log('await');
}

run();
process.nextTick(() => console.log('nextTick'));

Вывод: nextTick, await. Await ставит promise reaction. nextTick попадает в отдельную очередь Node. Node сначала дренирует nextTick, затем микрозадачи V8.

Fire-and-forget меняет порядок. Async-функция стартует сразу, работает до первого await, затем вызывающий идёт дальше без сохранённого где-либо handle завершения.

1
2
3
4
5
6
7
async function save(data) {
    await db.insert(data);
    console.log('saved');
}

save(myData);
console.log('continuing');

continuing печатается раньше saved. Так и задумано — возможно. Путь ошибки требует явного catch, когда это действительно задумано.

Обработка ошибок

Оберните в try/catch те await, чьи сбои вы можете обработать.

1
2
3
4
5
6
7
8
9
async function loadUser(id) {
    try {
        const user = await fetchUser(id);
        return user;
    } catch (e) {
        console.error('fetch failed:', e.message);
        return null;
    }
}

Если fetchUser(id) отклоняется, выражение await бросает. catch получает причину rejection. Если локальные catch промахиваются, промис, возвращённый async-функцией, отклоняется.

1
2
3
4
5
6
async function loadUser(id) {
    const user = await fetchUser(id);
    return user;
}

loadUser(99).catch((e) => console.error(e.message));

Rejection проходит по возвращённым промисам, пока вызывающий его не обработает. Оставьте без обработки — Node считает это unhandled rejection (см. предыдущую подглаву).

Ошибки с нескольких await можно собрать в один catch, если реакция одинаковая. catch становится границей для всего региона. Держите регион достаточно малым, чтобы сообщение в логе ещё что-то значило.

1
2
3
4
5
6
7
8
9
async function pipeline() {
    try {
        const raw = await fetchData();
        const parsed = await parseData(raw);
        return await saveData(parsed);
    } catch (e) {
        console.error('pipeline failed:', e);
    }
}

Широкий catch уместен, когда восстановление одинаково на каждом шаге. Используйте меньшие регионы, когда сообщение или восстановление различаются по шагам.

1
2
3
4
5
6
7
8
9
async function pipeline() {
    let raw;
    try {
        raw = await fetchData();
    } catch (e) {
        throw new Error('fetch: ' + e.message);
    }
    return await parseData(raw);
}

Скучный баг — и самый частый: промис создали и выбросили.

1
2
3
4
async function process() {
    doSomethingAsync();
    console.log('done');
}

doSomethingAsync() стартует. Её промис никуда не попадает. При rejection локальной обработки нет. Линтеры называют это floating promises. Считайте дефектом, если fire-and-forget не намеренный и к промису не прикреплена обработка ошибок.

1
2
3
doSomethingAsync().catch((e) => {
    console.error('background task failed:', e);
});

У return await есть одно законное применение, которое постоянно всплывает: поймать возвращаемый промис внутри текущей функции.

1
2
3
4
5
6
7
async function risky() {
    try {
        return await doSomethingAsync();
    } catch (e) {
        console.error('caught:', e);
    }
}

Прямой return отдаёт промис из doSomethingAsync() вызывающему до того, как try увидит rejection. С return await функция остаётся внутри try, пока промис не завершится. Используйте эту форму для локальной очистки, обёртки, логирования или когда важен стек.

Прямой return легче, когда функция только пробрасывает промис:

1
2
3
4
5
function getUser(id) {
    return db.query('SELECT * FROM users WHERE id = ?', [
        id,
    ]);
}

Async-обёртка даёт место для преобразования или локальной обработки ошибок:

1
2
3
4
5
6
7
async function getUser(id) {
    const row = await db.query(
        'SELECT * FROM users WHERE id = ?',
        [id]
    );
    return normalizeUser(row);
}

Обе версии возвращают промис вызывающему. Вторая аллоцирует состояние async-функции. Обычно это нормально. В горячих внутренностях библиотек с очень высокой частотой вызовов — измеряйте.

finally работает с awaited-очисткой.

1
2
3
4
5
6
7
8
async function withLock(resource, fn) {
    await resource.lock();
    try {
        return await fn();
    } finally {
        await resource.unlock();
    }
}

Если fn() отклоняется и unlock() тоже бросает, побеждает rejection из finally. То же правило, что у синхронного finally: throw при очистке заменяет предыдущую ошибку. Опасно. Код очистки должен быть маленьким, протестированным и громким при сбое.

Очистку часто кладут в finally, даже когда функция возвращает значение прямо из try. return ждёт завершения await в cleanup. Подходит для lock, временных файлов, span, транзакций и file handle.

Паттерны, которые имеют значение

Последовательные await явны. Иногда это именно то, что нужно.

1
2
3
for (const migration of migrations) {
    await runMigration(migration);
}

Миграции БД, упорядоченные записи и rate-limited вызовы часто требуют такой формы. Следующий шаг стартует после завершения предыдущего.

Независимую работу нужно запускать вместе.

1
2
3
4
5
6
async function fetchAll(urls) {
    const responses = await Promise.all(
        urls.map((url) => fetch(url))
    );
    return Promise.all(responses.map((r) => r.json()));
}

Вызов fetch(url) стартует работу. await ждёт результат. При независимых операциях сначала соберите промисы, затем await-ьте их вместе. Подглава 06 подробно разбирает комбинаторы, включая поведение при сбоях и ограничение параллелизма.

Различие — старт против ожидания. Часто await fetch(url) воспринимают как саму операцию. Вызов функции запускает операцию; await наблюдает завершение. Это различие и позволяет перекрывать независимую работу.

Методы итерации массивов заслуживают подозрения с async-колбэками.

1
2
3
4
5
6
urls.forEach(async (url) => {
    const res = await fetch(url);
    console.log(await res.text());
});

console.log('done');

forEach игнорирует промисы, которые возвращает async-колбэк. Финальный лог выполняется сразу. Ошибки внутри колбэков становятся unhandled, если каждый колбэк сам не ловит их. Для последовательной работы — for...of, для параллельной — Promise.all(urls.map(...)).

Top-level await (см. главу 1) делает async IIFE реже в ES-модулях, но CommonJS всё ещё использует паттерн:

1
2
3
4
5
(async () => {
    const config = await loadConfig();
    const server = await startServer(config);
    console.log('listening on', server.address().port);
})();

Для скриптов и старых CommonJS-модулей это нормально. В ESM на Node v24 доступен await на уровне модуля.

Не оборачивайте async-код в свежий конструктор Promise.

1
2
3
4
5
6
async function getData() {
    return new Promise(async (resolve) => {
        const data = await fetch('/api');
        resolve(data);
    });
}

Внешняя async-функция уже возвращает промис. Async executor создаёт ещё один путь промиса и может уродливо потерять rejections. Пишите функцию напрямую.

1
2
3
async function getData() {
    return fetch('/api');
}

Оборачивайте callback API в промис, когда источник — колбэк. Не оборачивайте промисы промисами.

Баг с async executor заслуживает жёсткой формулировки. Executor в new Promise() должен вызывать resolve или reject напрямую. Пометка executor как async заставляет его вернуть свой промис. Если executor бросает после await, rejection принадлежит промису executor, а внешний промис может остаться pending в зависимости от пути. Плохой режим отказа: ни fulfillment, ни rejection — просто зависание.

Форма для production

В production-коде на Node v24 async/await должен быть формой по умолчанию для прикладной логики. Это совпадает с экосистемой: HTTP-клиенты, драйверы БД, очереди, test runner и хуки фреймворков в основном говорят на промисах.

Профилирование обычно указывает куда-то ещё. Сетевой вызов — миллисекунды. Продолжение после await — микросекунды. Если профиль говорит, что overhead async важен, первая правка — батчинг, лимит параллелизма или случайные последовательные await. Переписывание читаемого async/await в сырые цепочки .then() мало что даёт вне внутренностей библиотек и узких циклов.

Памяти стоит уделить больше внимания. Каждая приостановленная async-функция удерживает локалы. Держите области видимости компактными вокруг await. Сбрасывайте большие ссылки перед долгими ожиданиями.

1
2
3
4
5
6
async function handle(req) {
    let body = await readBody(req);
    const parsed = parseRequest(body);
    body = null;
    return await db.save(parsed);
}

body = null явно показывает намерение: сырой payload может умереть до await базы. V8 может оптимизировать и сам, но снятие ссылки даёт сборщику разрешение.

Большие fan-out нужно ограничивать.

1
2
3
const results = await Promise.all(
    items.map((item) => transform(item))
);

Для 100 элементов — нормально. Для 100 000 — 100 000 промисов и, вероятно, 100 000 async-продолжений. Батчьте работу или используйте limiter параллелизма. Thread pool, база, удалённый API и куча имеют пределы, даже когда синтаксис укладывает fan-out в одну строку.

Простая батч-форма скучна и эффективна:

1
2
3
4
for (let i = 0; i < items.length; i += 100) {
    const batch = items.slice(i, i + 100);
    await Promise.all(batch.map((item) => transform(item)));
}

В полёте остаётся только 100 transform. Правильное число зависит от downstream. Базы, очереди и API обычно сообщают лимит через latency, ошибки или заголовки rate limit.

Что имеет смысл делать в production:

  • Async/await для request handler и бизнес-логики.
  • Floating promises — баг, если нет явного .catch().
  • return await внутри try/catch или finally; иначе — прямой return промиса.
  • Promise.all() для независимой работы; для больших батчей — ограниченный параллелизм.
  • Крупные буферы и разобранные payload не держать в области видимости перед долгими await.
  • for...of для упорядоченных async-циклов.
  • Избегать async-колбэков с forEach, filter, some, every и sort.
  • Профилировать до замены async/await на .then() ради скорости.

Модель стоимости

Каждый await стоит promise reaction, ход микрозадачи и suspend/resume состояния async-функции. Node v24 здесь быстр. Но это всё же стоимость.

Сырые цепочки .then() в микробенчмарках могут быть чуть быстрее: нет объекта async-функции и части восстановления состояния. Разница обычно 5–15% в крошечных циклах почти без реального I/O. В серверном коде, где доминируют сокеты, чтение файлов, вызовы БД и таймеры, разница тонет.

Стоимость видна в коде, который создаёт огромное число экземпляров async-функций с крошечными телами:

1
2
3
const out = await Promise.all(
    items.map(async (item) => transformSyncPart(item))
);

Если transformSyncPart() по сути синхронна, async-колбэк добавляет аллокацию промиса и async-функции без выигрыша в планировании. Для синхронной работы — синхронный map(). Async-функции — для работы, пересекающей async-границу.

Более частый провал производительности — случайная сериализация:

1
2
3
for (const user of users) {
    await sendEmail(user);
}

Верно для rate-limited или упорядоченной отправки. Медленно для независимых писем. Синтаксис не доказывает намерение. Зависимость данных подсказывает.

Отладка улучшилась настолько, что async/await обычно стоит оставить. Async stack traces включены по умолчанию в современном Node. Inspector шагает через await и показывает локалы, пока функция приостановлена. Читаемый исходник и пригодные стеки. Для большинства кода, который я хочу поддерживать, это лучше ручной цепочки промисов.

Связанное чтение

Комментарии