Event Loop в Node.js: как я объясняю его себе на практике
Event loop важен не для собеседований, а для понимания задержек, блокировок, очередей, таймеров, Promise microtasks и поведения backend под нагрузкой.
Event loop часто объясняют через картинки с кругами, стрелками и фазами. Это полезно, но у меня долго оставалось ощущение, что я понимаю схему, но не до конца понимаю последствия.
На практике event loop важен не для того, чтобы правильно ответить на вопрос на собеседовании. Он важен для другого: понимать, почему backend “подвисает”, почему setTimeout(..., 0) не выполняется сразу, почему Promise callbacks могут задержать timers, почему CPU-heavy код ломает отзывчивость Node.js и почему очередь задач - это не магия параллельности.
Node.js не делает JavaScript параллельным
JavaScript-код в Node.js выполняется в одном основном потоке. Если этот поток занят долгим синхронным вычислением, event loop не может перейти к следующей задаче.
Простой пример:
setTimeout(() => {
console.log('timer');
}, 0);
const startedAt = Date.now();
while (Date.now() - startedAt < 3000) {
// blocking work
}
console.log('sync finished');
timer не выполнится через 0 миллисекунд. Он выполнится только после того, как освободится call stack. Это ключевая мысль: event loop не прерывает текущий JavaScript-код. Он ждет, пока синхронная работа закончится.
Что тогда делает event loop
Event loop координирует выполнение callbacks из разных источников:
- timers:
setTimeout,setInterval; - I/O callbacks: сеть, файловая система, сокеты;
- poll: ожидание новых I/O событий;
- check:
setImmediate; - close callbacks: закрытие сокетов и ресурсов;
- microtasks:
Promise.then,queueMicrotask; process.nextTick, который в Node.js имеет особый приоритет.
Грубая модель такая: синхронный код выполняется сразу, затем Node.js разбирает microtasks, затем переходит между фазами event loop и берет callbacks из соответствующих очередей.
Microtasks могут быть опаснее, чем кажутся
Promise callbacks обычно воспринимаются как “почти сразу после текущего кода”. Это верно, но у этого есть обратная сторона: длинная цепочка microtasks может задержать переход event loop к timers и I/O.
function spin(count: number) {
if (count === 0) return;
Promise.resolve().then(() => spin(count - 1));
}
setTimeout(() => console.log('timer'), 0);
spin(100_000);
Таймер будет ждать, пока очередь microtasks не опустеет. В реальном backend такое может проявляться не так явно: много Promise-цепочек, сериализация больших данных, сложные synchronous callbacks после await - и latency начинает расти.
process.nextTick еще чувствительнее
В Node.js process.nextTick выполняется раньше обычных Promise microtasks. Это удобно для некоторых низкоуровневых API, но злоупотребление может буквально “морить голодом” event loop.
function loop() {
process.nextTick(loop);
}
loop();
Такой код не даст event loop нормально перейти к I/O. В прикладном коде я стараюсь не использовать process.nextTick без очень понятной причины.
setTimeout(0) и setImmediate
Один из классических вопросов: что выполнится раньше - setTimeout(..., 0) или setImmediate?
Ответ зависит от контекста. На верхнем уровне порядок может быть неочевидным. Внутри I/O callback setImmediate обычно выполнится раньше timer'а, потому что он попадает в check phase после poll.
Но в прикладной разработке важнее не заучить “кто первый”, а понимать намерение:
setTimeout- выполнить не раньше указанной задержки;setImmediate- отложить выполнение до check phase;queueMicrotask- выполнить после текущего call stack до перехода к новым macrotasks.
Главный враг backend-а - blocking code
Node.js хорошо подходит для I/O-heavy задач: API, запросы к базе, сеть, очереди, файловые операции, realtime. Но если положить в request handler тяжелую синхронную работу, весь процесс начнет хуже отвечать на другие запросы.
Типичные проблемы:
- большой JSON parse/stringify;
- синхронная криптография;
- генерация больших документов в основном потоке;
- обработка изображений;
- сложные циклы по большим массивам;
- регулярные выражения с catastrophic backtracking;
- синхронные filesystem операции.
Если задача CPU-heavy, лучше подумать про worker threads, отдельный сервис, очередь или вынос обработки из пользовательского запроса.
Как я применяю это в backend
Когда я проектирую endpoint, я задаю несколько вопросов:
- есть ли внутри тяжелая синхронная работа;
- можно ли вернуть ответ раньше, а обработку вынести в job;
- не будет ли Promise-цепочка слишком длинной;
- можно ли стримить данные вместо сборки всего результата в памяти;
- что случится с latency, если таких запросов будет много одновременно;
- достаточно ли логов, чтобы увидеть event loop lag.
Event loop - это не абстрактная тема из JavaScript. Это часть эксплуатационного поведения backend-а.
Вывод
Понимать event loop - значит понимать границу между “асинхронно” и “параллельно”. await не делает CPU-bound код безопасным. Promise не отменяет blocking. Таймер не гарантирует выполнение ровно через указанное время.
Для backend-разработчика это практическое знание: оно помогает проектировать очереди, не блокировать request handlers, объяснять latency и выбирать правильное место для тяжелых задач.