Event Loop крутится, Node.js мутится. Часть 2. Нюансы работы Event Loop.
Anastasia KotovaПредыдущие части
Event Loop крутится, Node.js мутится. Часть 1. Браузер VS сервер.
В предыдущей статье мы разобрали, как базово работает Event Loop в Node.js и какие его основные отличия от браузерного. Теперь погрузимся в нюансы его работы.
Предсказать порядок выполнения
Кажется, что если фазы Event Loop следуют строго друг за другом, то порядок выполнения кода можно легко предсказать. Но это не всегда так.
Один из примеров — setTimeout(0) и setImmediate().
setTimeout(() => console.log("⏳ setTimeout"), 0);
setImmediate(() => console.log("⏩ setImmediate"));
Интуитивно можно подумать, что setTimeout(0) сработает раньше, ведь Timers Phase идёт первой. Однако, если запустить код, результат может отличаться:
⏳ setTimeout ⏩ setImmediate
или
⏩ setImmediate ⏳ setTimeout
Почему так происходит?
Event Loop проходит через фазы в следующем порядке:
Timers → Pending Callbacks → Idle, Prepare → Poll → Check → Close Callbacks
setTimeout(0)выполняется в Timers Phase, которая запускается в начале новой итерации Event LoopsetImmediate()выполняется в Check Phase, которая идёт сразу после Poll.
Но для того, чтобы setTimeout(0) попал в очередь выполнения, системе нужно некоторое время. Если Event Loop уже переключился на Poll и обнаружил, что больше задач нет, он сразу переходит к Check Phase, где выполняется setImmediate(). А setTimeout(0), если не успел обработаться в первой итерации, будет ждать следующего цикла.
Из этого следует и то, что задержка в setTimeout() — это не гарантированное время выполнения, а минимальное время ожидания перед запуском колбэка.
Однако, если запустить те же операции внутри I/O-коллбэка, порядок всегда предсказуем:
fs.readFile(__filename, () => {
setTimeout(() => console.log("⏳ setTimeout"), 0);
setImmediate(() => console.log("⏩ setImmediate"));
});
Когда код выполняется внутри I/O-коллбэка, Event Loop уже находится в Poll Phase. После обработки Poll Phase всегда наступает Check Phase (где выполняется setImmediate()), а только затем Timers Phase (setTimeout(0)).
Что на самом деле происходит в фазах “Pending Callbacks” и “Idle, Prepare”
Ранее мы обошли эти две фазы стороной, но теперь разберёмся, какую роль они играют в Event Loop.
Фаза Pending Callbacks выполняется сразу после Timers Phase и перед Idle, Prepare Phase. Её основная задача — обработать отложенные системные колбэки, которые не относятся к таймерам или I/O, но требуют выполнения в ближайшем цикле. По сути, это буфер для событий, которые не могут быть обработаны в Poll Phase. Без неё ошибки могли бы потеряться или выполняться с задержкой. Например, сюда попадают ошибки в TCP/UDP-соединениях.
Пример кода, в котором можно увидеть эту фазу в работе:
const fs = require("fs");
const net = require("net");
console.log("🟢 1. Начало синхронного кода");
// 🔹 Ошибка в TCP-соединении (Pending Callbacks Phase)
const socket = net.connect(9999, "127.0.0.1"); // Не существует
socket.on("error", () => console.log("📌 Pending Callbacks Phase: TCP ошибка"));
// 🔹 setTimeout (Timers Phase)
setTimeout(() => console.log("⏳ setTimeout (Timers Phase)"), 0);
// 🔹 setImmediate (Check Phase)
setImmediate(() => console.log("⏩ setImmediate (Check Phase)"));
// 🔹 I/O операция (Poll Phase)
fs.readFile(__filename, () => {
console.log("📂 fs.readFile (Poll Phase)");
});
// 🔹 Закрытие потока (Close Callbacks Phase)
const readable = fs.createReadStream(__filename);
readable.close();
readable.on("close", () => console.log("🚪 onClose (Close Callbacks Phase)"));
console.log("🟢 2. Конец синхронного кода");
Порядок вывода будет такой:
🟢 1. Начало синхронного кода 🟢 2. Конец синхронного кода ⏳ setTimeout (Timers Phase) 📌 Pending Callbacks Phase: TCP ошибка ⏩ setImmediate (Check Phase) 🚪 onClose (Close Callbacks Phase) 📂 fs.readFile (Poll Phase)
Как видно, TCP-ошибка срабатывает после Timers Phase, но до остальных фаз.
Фаза Idle, Prepare — это техническая фаза Event Loop, а точнее, две фазы, которые часто рассматривают как одну. Они выполняются сразу после Pending Callbacks, перед Poll Phase, и используются исключительно внутри движка Node.js. В них нельзя напрямую записывать колбэки, поэтому они редко упоминаются.
Idle Phase отвечает за ожидание новых задач, если в Poll Phase пока ничего нет. Prepare Phase используется для подготовки событий перед Poll Phase, например, планирование будущих I/O-операций.
Эти фазы остаются «за кулисами» работы Node.js, но играют важную роль в том, как Event Loop управляет асинхронностью.
Зачем нужен process.nextTick()
Мы уже говорили, что process.nextTick() — это механизм, который позволяет выполнять код немедленно после завершения текущего выполнения, но до выхода в Event Loop. При этом он имеет приоритет даже перед микротасками. Но если у нас уже есть Promise.then() и setImmediate(), зачем нужен ещё один способ отложенного выполнения?
На первый взгляд, Promise.then() тоже выполняется до макрозадач и кажется, что его можно использовать вместо nextTick(). Однако в Node.js есть задачи, которые требуют выполнения строго перед любыми другими операциями. Например, обработка ошибок. Если внутри асинхронной функции произошла ошибка, её обработчик должен выполниться до любых других задач, чтобы не допустить некорректного состояния программы. process.nextTick() гарантирует, что этот код выполнится раньше, чем любые другие операции, даже если в очереди уже есть Promise.then(). Если бы обработка ошибки была запланирована через микротаски, она могла бы выполниться позже других асинхронных операций, что в некоторых случаях могло бы привести к трудноуловимым багам.
Другой важный случай использования process.nextTick() — серверные приложения. Например, когда мы вызываем server.listen(), сервер может мгновенно привязаться к порту и начать принимать соединения. В это время обработчик события "listening" может быть ещё не вызван. Если клиент подключится сразу после запуска, есть вероятность, что "connection" сработает до "listening", что выглядит нелогично.
Пример:
const net = require("net");
const server = net.createServer();
server.listen(8080);
server.on("listening", () => {
console.log("👂 Сервер запущен и слушает порт 8080");
});
server.on("connection", (conn) => {
console.log("🔗 Новое соединение!");
});
В этом коде, если Event Loop пойдёт в Poll Phase раньше, чем обработается "listening", сервер может уже принять соединение. Это может привести к тому, что "connection" сработает до того, как мы узнаем, что сервер запущен.
Тогда мы могли бы получить такой странный вывод:
🔗 Новое соединение! 👂 Сервер запущен и слушает порт 8080
Использование process.nextTick() для обработчика "listening" гарантирует, что сервер корректно уведомит о своём запуске до того, как начнёт обрабатывать соединения.
Как зациклить Node.js
Несмотря на удобство process.nextTick(), его нельзя использовать слишком часто, потому что он может заблокировать Event Loop. Если бесконечно ставить новые process.nextTick() внутри уже выполняющегося колбэка, Event Loop никогда не дойдёт до следующих фаз, и код просто зависнет:
function infiniteTick() {
console.log("⚠ Бесконечный nextTick");
process.nextTick(infiniteTick);
}
infiniteTick();
Этот код зависнет, потому что process.nextTick() выполняется до любых других фаз, и Event Loop никогда не перейдёт к следующим операциям.
Именно поэтому process.nextTick() стоит использовать осознанно, только в тех ситуациях, когда Promise.then() или setImmediate() не дают нужного эффекта.
Интересно также, что в браузере process.nextTick() отсутствует, и на это есть важная причина. В отличие от Node.js, браузерный Event Loop управляет не только выполнением JavaScript, но и отрисовкой интерфейса, обработкой пользовательских событий и другими критическими задачами. Если бы браузер поддерживал process.nextTick(), разработчики могли бы случайно заблокировать интерфейс, вызывая nextTick() бесконечно. Это сделало бы страницу полностью зависшей, так как Event Loop не смог бы переключиться на обработку рендеринга. В браузерах эту функцию заменяют микротаски, которые выполняются до макрозадач, но не блокируют обновление интерфейса.
Выводы
Во второй части мы углубились в более сложные аспекты Event Loop и разобрали те ситуации, в которых порядок выполнения кода может быть не таким очевидным, как кажется. Мы выяснили, почему setTimeout(0) и setImmediate() иногда меняются местами, как на самом деле работают фазы Pending Callbacks и Idle, Prepare, а также разобрались, почему в браузерах нет process.nextTick().
Кроме того, мы увидели, что process.nextTick() — это не просто более приоритетная микротаска, а инструмент, который критически важен для правильного порядка выполнения кода в Node.js. С его помощью можно корректно обрабатывать ошибки, управлять серверными соединениями и гарантировать выполнение важного кода до выхода в Event Loop.
Как и в прошлый раз, за кулисами остаётся ещё много интересных тем. Чем глубже мы погружаемся в Event Loop, тем больше вопросов возникает. Но это только делает изучение Node.js ещё интереснее!
Следующие части
Event Loop крутится, Node.js мутится. Часть 3. За пределами Event Loop.
Event Loop крутится, Node.js мутится. Часть 4. HTTP-сервер.
Event Loop крутится, Node.js мутится. Часть 5. Настоящая многопоточность.
Event Loop крутится, Node.js мутится. Часть 6. Профилировать и замерять.