В Node почти любой ввод-вывод асинхронный. За годы сменились три стиля: колбэки, промисы и async/await. На собесе проверяют, умеешь ли ты запускать операции параллельно, ограничивать их число, отменять и не терять ошибки.
От колбэков к async/await
Исторический стиль Node это error-first callback: первым аргументом приходит ошибка, вторым результат.
fs.readFile('a.txt', 'utf8', (err, data) => {
if (err) return handle(err);
use(data);
});
Вложенные колбэки быстро превращаются в «пирамиду», а ошибки легко забыть. Колбэк-API превращают в промисы через util.promisify, но у большинства модулей уже есть промис-версия: node:fs/promises, node:timers/promises, node:stream/promises.
async-функция всегда возвращает промис. await приостанавливает только эту функцию, а не весь процесс: остальной код и другие запросы продолжают работать. Тело до первого await выполняется синхронно.
Последовательно или параллельно
// последовательно: 200 мс
const a = await sleep(100);
const b = await sleep(100);
// параллельно: 100 мс
const [a, b] = await Promise.all([sleep(100), sleep(100)]);
Частая ошибка: ставить await в цикл там, где запросы независимы. Обратная ошибка: array.forEach(async ...). forEach не ждёт промисы, поэтому код после цикла выполнится раньше, а ошибки улетят в unhandled rejection. Для последовательной обработки нужен for...of с await, для параллельной Promise.all(array.map(...)).
Комбинаторы промисов
| Метод | Когда выполнится | Когда отклонится |
|---|---|---|
Promise.all | все успешны (массив результатов) | при первой ошибке |
Promise.allSettled | когда все завершились (статусы) | никогда |
Promise.race | первый завершившийся, успешно | первый завершившийся с ошибкой |
Promise.any | первый успешный | все упали: AggregateError |
Promise.all при ошибке не отменяет остальные операции: они продолжают выполняться, просто их результат никто не ждёт.
Ограничение параллелизма
Promise.all на 10 000 URL откроет 10 000 соединений разом: упрёшься в лимиты API, файловых дескрипторов или пула БД. Нужен пул: одновременно не больше N задач.
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
В проде то же самое дают пакеты вроде p-limit или параметр конкурентности у очередей.
Отмена и таймауты
Промис нельзя отменить сам по себе. Стандартный механизм это AbortController: операция принимает signal и прерывается, когда вызывают abort().
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
AbortSignal.timeout(ms) прерывает с ошибкой TimeoutError, ручной abort() даёт AbortError. Сигнал принимают fetch, fs/promises, timers/promises, events.once, stream.pipeline. Promise.race с таймером даёт только видимость таймаута: исходная операция продолжает работать.
EventEmitter
Многие объекты Node (сервер, потоки, сокеты) это EventEmitter. Важно:
emitвызывает слушателей синхронно, по порядку подписки.onceсрабатывает один раз.- Событие
errorособое: если у эмиттера нет слушателяerror,emit('error')бросит исключение и обычно уронит процесс. - Больше 10 слушателей одного события дают предупреждение о возможной утечке: часто это подписка в цикле или на каждый запрос без отписки.
await once(emitter, 'event')изnode:eventsпревращает событие в промис.
Частые ошибки
awaitв цикле для независимых операций.forEach(async ...)и ожидание, что цикл дождётся.Promise.allбез ограничения на тысячи задач.return promiseвнутриtryбезawait: ошибка не попадёт вcatchэтой функции.- Забытый
await: ошибка превращается в unhandled rejection.
Что спросят на собесе
- Как запустить запросы параллельно и чем отличаются
all,allSettled,race,any. - Как ограничить число одновременных запросов.
- Как отменить запрос или сделать таймаут.
- Почему
forEachсasyncне работает как ожидается. - Как работает EventEmitter и что особенного в событии
error.