Асинхронные паттерны и обработка ошибок
Callback, promise и async/await — три лица одной модели. Композиция через Promise.all, прокидывай rejection вместо проглатывания, отменяй через AbortController и помни: microtask дренируются до следующего macrotask.
Обработчик запроса доставал пользователя, потом его заказы, потом позиции каждого заказа — всё через await внутри цикла for. Это работало. И это занимало 4.2 секунды для пользователя с двенадцатью заказами, потому что каждый await блокировал следующий, хотя поиски заказов были полностью независимы. Один ревьюер переписал внутренний цикл как Promise.all(orders.map(fetchLineItems)), и обработчик упал до 380 мс. Те же API, те же данные — разница была лишь в том, ждал ли код того, чего ждать в очередь было незачем.
Callback: контракт error-first и его потолок
Изначальный async-стиль Node — это callback: ты передаёшь функцию, которая запустится, когда работа закончится. Конвенция — error-first: первый аргумент callback’а — это ошибка (или null), остальное — результат. Ты проверяешь ошибку до того, как трогать данные, каждый раз.
fs.readFile("config.json", "utf8", (err, data) => {
if (err) return console.error("read failed:", err);
const config = JSON.parse(data); // безопасно только когда err === null
console.log(config.port);
});Это работает, но не композируется. Выстрой в цепочку три зависимых операции — и получишь вложенность, заслужившую собственное имя: callback hell, где каждый шаг живёт на отступ глубже, а обработка ошибок скопирована на каждый уровень. Хуже структурная проблема: инверсия управления. Ты отдаёшь своё продолжение в чужую функцию и доверяешь ей вызвать тебя ровно один раз, с правильными аргументами, в правильный тик. Багованная библиотека, которая зовёт callback дважды, или никогда, или синхронно, когда ты ждал async, ломает твой поток — и в системе типов ничто этого не остановит. Promise существуют во многом чтобы забрать это управление обратно.
Promise: значение, которым владеешь ты, с комбинаторами
Почему callback’и оказались настолько неудобны, что язык получил совершенно новую модель? Потому что держать результат у себя — а не отдавать своё продолжение в чужие руки — это то, что делает композицию возможной.
Promise — это объект, представляющий будущее значение. У него три состояния — pending, затем ровно одно из fulfilled (resolve со значением) или rejected (упал с причиной), — и, осев один раз, оно больше не меняется. Поскольку promise — это полноценное значение, которое держишь ты, ты композируешь его вместо передачи управления: .then цепляет преобразования, .catch обрабатывает падения, а rejection пропускает каждый .then, пока не дойдёт до .catch. Этот единственный путь rejection — лекарство от скопированной обработки ошибок.
Настоящая сила — в комбинаторах для одновременного запуска нескольких promise. Выбор неправильного — частая ошибка среднего уровня:
| Комбинатор | Оседает когда | При rejection | Применяй для |
|---|---|---|---|
Promise.all | все fulfill, или первый reject | reject сразу (fail-fast) | all-or-nothing fan-out, где любое падение прерывает |
Promise.allSettled | каждый promise осел | никогда не reject; статус по каждому | независимые задачи, где частичный успех ок |
Promise.race | первый осевший (fulfill или reject) | reject, если первый осевший упал | таймауты — гонка работы против таймера |
Promise.any | первый fulfill | reject, только если все упали (AggregateError) | первый годный результат из дублирующих источников |
Вместе эти четыре покрывают все формы fan-out: all для all-or-nothing, allSettled для частичного успеха, race для «кто быстрее», any для «первый победитель». Без правильного комбинатора ты или теряешь уже полученные результаты, или прячешь нужные тебе сбои.
Классическая ловушка — использовать Promise.all для задач, где частичный успех допустим: один rejection тогда выбрасывает результаты всего остального, что уже успешно завершилось. Если нужны все результаты независимо от отдельных падений, allSettled — правильный инструмент; all — для случая, когда любое падение действительно инвалидирует весь батч.
async/await: те же promise, читаются сверху вниз
async/await — это синтаксический сахар над promise, а не отдельный механизм. Функция async всегда возвращает promise — даже async () => 42 возвращает promise, который fulfill значением 42, — а await просто ставит функцию на паузу до тех пор, пока promise не осядет, разворачивая его значение или бросая его rejection. Поскольку rejection становится брошенной ошибкой, ты обрабатываешь их обычным try/catch, что читается куда лучше, чем цепочка .catch.
async function loadDashboard(userId) {
try {
// независимое — запускаем параллельно, не в очередь
const [user, orders] = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
]);
return { user, orders };
} catch (err) {
// один catch покрывает оба rejection
throw new Error(`dashboard load failed for ${userId}`, { cause: err });
}
}Самый частый баг производительности здесь — последовательный await в цикле, когда итерации независимы. for (const id of ids) { await fetchOne(id); } запускает запросы один за другим; суммарное время — это сумма всех задержек. Если вызовы не зависят друг от друга, запусти их вместе через Promise.all(ids.map(fetchOne)), и суммарное время схлопнется примерно до самого медленного одного вызова. Последовательный await корректен только когда каждому шагу действительно нужен результат предыдущего.
▸Почему это работает
Не мешай await и .then беспечно на одной операции. await fetchUser().then(u => u.name) работает, но затемняет, куда падают ошибки, и читается как две парадигмы, сшитые степлером. Выбирай одну на логический поток. Тоньше баг: запустить promise и заawait’ить его позже — нормально и даже полезно для параллелизма, но если ты создал promise и никогда не await’ишь и не .catch’ишь его, позднейший rejection становится unhandled rejection — см. обработку ошибок ниже.
Обработка ошибок: никогда не проглатывай, решай прокинуть vs обработать
Async-ошибка, которую ты игнорируешь, не исчезает — она становится unhandledRejection. В современном Node promise, который reject без .catch (и без окружающего try/catch на его await), эмитит process-событие unhandledRejection и по умолчанию роняет процесс. Это умолчание намеренно: наполовину завершённый запрос в неизвестном состоянии опаснее, чем перезапуск.
Правило — никогда не проглатывай ошибки: пустой catch {}, который ничего не логирует, — это то, как аварии становятся необъяснимыми. На каждое падение ты принимаешь одно решение: обработать его (можешь восстановиться — retry, откат к дефолту, вернуть 503) или прокинуть его (не можешь, поэтому re-throw и дай вызывающему с бо́льшим контекстом решить). Блок catch, который ни восстанавливает, ни re-throw’ит, почти всегда баг.
Отмена — вторая половина. Запрос, который пользователь бросил, или fetch, бежавший слишком долго, надо остановить — и современный, стандартный инструмент это AbortController. Ты передаёшь его signal в async-API; вызов controller.abort() reject’ит операцию с AbortError. Привязка к таймеру даёт чистый таймаут без утечки выполняющейся работы:
async function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer); // всегда чистим таймер
}
}Порядок event loop: microtask дренируются до следующего macrotask
Сеньорский вопрос — когда твои callback’и реально запускаются. Event loop обрабатывает macrotask (таймеры вроде setTimeout и I/O-callback’и) по одному, — но после каждого macrotask, прежде чем перейти к следующему, он полностью дренирует очередь microtask: callback’и осевших promise, queueMicrotask и process.nextTick Node. Microtask всегда бегут до следующего macrotask, а внутри microtask Node запускает очередь nextTick впереди очереди promise.
Этот порядок объясняет вывод, который удивляет:
console.log("1: sync");
setTimeout(() => console.log("2: timeout (macrotask)"), 0);
Promise.resolve().then(() => console.log("3: promise (microtask)"));
queueMicrotask(() => console.log("4: queueMicrotask (microtask)"));
process.nextTick(() => console.log("5: nextTick (microtask, first)"));
console.log("6: sync");
// 1: sync → 6: sync → 5: nextTick → 3: promise → 4: queueMicrotask → 2: timeoutСначала бегут обе синхронные строки. Затем текущая операция заканчивается и очередь microtask дренируется — nextTick впереди задач promise/queueMicrotask, — и только после опустошения очереди срабатывает macrotask setTimeout, хотя его задержка была 0. Практическая выгода: callback process.nextTick может заморить I/O, если продолжает планировать новую работу nextTick, а цепочка promise никогда не уступает таймеру посреди дренажа. Знание порядка — это то, как ты рассуждаешь, почему таймаут «0 мс» всё равно бежит последним.
Ты делаешь fan-out из N независимых API-вызовов. Некоторые могут упасть, и ты хочешь использовать каждый успешный результат. Какой комбинатор?
Цикл делает `for (const id of ids) { results.push(await fetchOne(id)); }`, и вызовы независимы. В чём проблема и фикс?
Что это выведет, по порядку? `console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D');`
- 01Когда использовать Promise.all vs allSettled vs race vs any?
- 02Объясни, почему callback setTimeout(fn, 0) бежит после callback Promise.then, запланированного до него.
Callback, promise и async/await — три синтаксиса над одной асинхронной моделью. Callback’и используют контракт error-first (err, data), но не композируются и отдают управление в чужую функцию; promise забирают это управление обратно как значение, которым владеешь ты, с единственным путём rejection через .catch и комбинаторами — Promise.all для fail-fast all-or-nothing fan-out, allSettled, когда важен частичный успех, race для таймаутов, any для первого годного результата. async/await — сахар над promise: async-функция всегда возвращает promise, await разворачивает или бросает, а падения ты обрабатываешь через try/catch. Повторяющийся баг производительности — последовательный await в цикле над независимой работой; распараллель его через Promise.all. Для ошибок никогда не проглатывай rejection (он становится unhandledRejection и может уронить процесс); на каждое падение решай, обработать или прокинуть, а долгую или брошенную работу отменяй через AbortController плюс таймер. Наконец, event loop дренирует всю очередь microtask — promise, queueMicrotask и process.nextTick (который бежит первым) — после текущей операции и до следующего macrotask, поэтому Promise.then всегда обгоняет setTimeout(…, 0). Теперь, когда видишь медленный обработчик на ревью, первый вопрос: эти await-ы в цикле действительно последовательны или их можно схлопнуть в Promise.all? Один вопрос ловит самую частую регрессию производительности в async-коде.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.