Асинхронность в Node.js объясняют одной фразой: неблокирующий ввод-вывод. За ней стоит механика, от которой зависит поведение сервера. Почему тяжёлый синхронный цикл останавливает все запросы? Дальше - Event Loop по шагам, на примерах из LTS-версии.
Почему асинхронность в Node.js не про потоки
По умолчанию JavaScript выполняется в одном потоке: инструкции идут строго по очереди. Ввод-вывод при этом идёт параллельно: операция уходит ядру ОС, а колбэк попадает в очередь, когда ядро сообщает о результате.
Блокирующей считают операцию, из-за которой поток ждёт результат и не берёт следующую задачу. Типичный пример - синхронные методы с суффиксом Sync. Подробнее в руководстве Node.js по блокирующим вызовам.
Когда вызов блокирует процесс, а когда нет
Асинхронный readFile отдаёт управление сразу: пока диск отвечает, поток обслуживает другие запросы. readFileSync возвращает данные в той же строке, но до конца чтения процесс не делает ничего. Под нагрузкой он задерживает всех остальных.
Как Event Loop проходит свои фазы
За одну итерацию Event Loop проходит фазы по порядку: timers, pending callbacks, idle и prepare, poll, check и close callbacks. У каждой своя очередь колбэков. Порядок фаз описан в документации по Event Loop.

Что решает фаза poll
Фаза poll ждёт ввод-вывод и разбирает свою очередь. Если очередь пуста, а setImmediate() запланирован, цикл переходит к check.
Порядок выполнения: nextTick, промисы и setImmediate
В CommonJS сначала разбирается process.nextTick, затем микротаски промисов, и только потом макротаски вроде setTimeout и setImmediate. В ESM первые две меняются местами. Очередь nextTick формально не входит в Event Loop: её разбирают после текущей операции, в какой бы фазе цикл ни стоял.
Пример - в CommonJS:
const baz = () => console.log('baz');
const foo = () => console.log('foo');
const zoo = () => console.log('zoo');
const start = () => {
console.log('start');
setImmediate(baz);
new Promise((resolve) => resolve('bar')).then((value) => {
console.log(value);
process.nextTick(zoo);
});
process.nextTick(foo);
};
start();
// start foo bar zoo bazСначала печатается start, затем nextTick выводит foo, а промис - bar. Колбэк zoo ставится в nextTick уже из микротаски, а baz из setImmediate ждёт фазы check и идёт последним.
Один и тот же код в CJS и ESM даёт разный порядок
Тот же код в .mjs выведет start bar foo zoo baz. ESM-модуль исполняется внутри микротаск-очереди, поэтому промис срабатывает раньше nextTick.
Рекурсивный nextTick - ловушка: Event Loop не доходит до poll, и ввод-вывод может заглохнуть. С версий v22.7.0 и v20.18.0 документация помечает process.nextTick как Legacy и рекомендует queueMicrotask(). Сам API при этом не удалён.
setTimeout и setImmediate: где порядок определён
Если оба вызова стоят внутри колбэка ввода-вывода, setImmediate срабатывает раньше таймера всегда. В главном модуле порядок setTimeout(fn, 0) и setImmediate недетерминирован и зависит от производительности процесса.
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// immediate
// timeoutКолбэк readFile выполняется в фазе poll, поэтому setImmediate попадает в check раньше таймера. Вынесите оба вызова в главный модуль, и гарантии не будет.
Таймер задаёт порог, а не точное время. Задержку увеличивают планировщик ОС и другие колбэки.
Что делает await, если поток не блокируется
await не блокирует поток. Он приостанавливает async-функцию, а её продолжение ставится в микротаск-очередь. Любая async-функция возвращает промис, и await просто ждёт его.
Async-функции из ES2017 появились в V8 5.5 (октябрь 2016), а в Node.js - с обновлением V8 в версии 7.6.0.
Ошибки и отмена: unhandledRejection и AbortSignal
Отклонённый промис без обработчика к концу текущей итерации Event Loop вызывает событие unhandledRejection. С Node.js 15.0.0 режим по умолчанию - throw: необработанное отклонение роняет процесс. Как это доходит до обработчика в Express - в статье про асинхронные обработчики в Express.
Для отмены есть AbortSignal.timeout(ms): сигнал срабатывает через ms миллисекунд. Появился в v17.3.0 и v16.14.0.
Promise.all, allSettled и any: как не ждать по одному
Цикл с await запускает операции по очереди: каждая ждёт предыдущую. Promise.all стартует все сразу. Если операции независимы, общее время равно самой долгой из них.
Пример для Linux и macOS, на Windows подставьте пути. Файл - .mjs: нужны ESM и top-level await.
import { readFile } from 'node:fs/promises';
const files = ['/etc/hostname', '/etc/hosts'];
// параллельно
const texts = await Promise.all(files.map((file) => readFile(file, 'utf8')));map возвращает массив промисов, Promise.all дожидается всех и отдаёт массив текстов.
Если одна из операций отклонится, весь Promise.all отклонится тоже. Здесь проходит граница с allSettled: Promise.allSettled дожидается всех промисов и возвращает исход каждого. Promise.any, наоборот, отдаёт первый успешный результат.
EventEmitter и синхронные слушатели
Значительная часть ядра Node.js построена на событиях, а такие объекты - экземпляры EventEmitter. Слушатели вызываются синхронно, в порядке регистрации, а возвращаемые значения игнорируются. Поэтому async-слушатель не заставит emit ждать свой промис.
Почему событие error без слушателя роняет процесс
Если у эмиттера нет слушателя 'error', ошибка выбрасывается, печатается стек и процесс завершается. Опция captureRejections перехватывает отклонения в async-слушателях, но по умолчанию выключена.
Тот же Event Loop обслуживает и WebSockets, и Nest.js.
📩 Нужна команда под задачу? Посмотрите, как мы ведём проекты: оставить заявку.



