Як працює Event Loop у Node.js
Event Loop (цикл подій) - це механізм, який дозволяє однопотоковому Node.js обробляти паралельні I/O-операції без блокування. Він делегує роботу ОС та libuv, а потім повертає готові callbacks через шість впорядкованих фаз.
Теорія
TL;DR
- Node.js однопотоковий, але не блокуючий: event loop передає I/O в libuv та ОС і підбирає результати коли вони готові
- Аналогія: офіціант, який приймає замовлення і продовжує обслуговувати інші столики, замість того щоб стояти і чекати
- Шість фаз за тік у фіксованому порядку: timers, pending callbacks, idle/prepare, poll, check, close
- Мікрозавдання (microtasks):
process.nextTickта Promise-callbacks повністю виконуються між кожним переходом між фазами, причому nextTick першим - Заблокуй головний потік важким CPU-циклом і весь цикл фаз зупиниться
Швидкий приклад
const fs = require('fs');
console.log('1: sync');
process.nextTick(() => console.log('2: nextTick')); // мікрозавдання, найвищий пріоритет
Promise.resolve().then(() => console.log('3: promise')); // мікрозавдання
setTimeout(() => console.log('4: timer'), 0); // фаза timers
setImmediate(() => console.log('5: immediate')); // фаза check
fs.readFile(__filename, () => {
setImmediate(() => console.log('6: I/O immediate')); // check, перед наступними timers
setTimeout(() => console.log('7: I/O timer'), 0);
});
// Виведення: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7
// Всередині I/O callback 6 завжди перед 7Спочатку виконується синхронний код. Потім мікрозавдання. Потім event loop проходить фази. Всередині I/O callback setImmediate завжди спрацьовує перед setTimeout(fn, 0), бо фаза check іде одразу після poll.
Як event loop працює зсередини
V8 виконує JavaScript синхронно в головному потоці. Коли код викликає fs.readFile або робить мережевий запит, Node.js передає роботу libuv - C-бібліотеці, що входить до складу Node. libuv використовує асинхронні API ядра (epoll на Linux, kqueue на macOS) для мережевого I/O, а для файлових операцій, DNS і crypto - власний пул потоків (за замовчуванням 4 потоки, змінюється через UV_THREADPOOL_SIZE).
Коли операція завершується, libuv кладе callback у чергу відповідної фази. Event loop проходить ці черги у фіксованому порядку. Один повний прохід називається тіком (tick).
Шість фаз тіку
┌───────────────────────────┐
┌─>│ timers │ <- callbacks setTimeout, setInterval
│ └─────────────┬─────────────┘
│ │ <- мікрозавдання (nextTick + Promise)
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ <- I/O callbacks відкладені з попередньої ітерації
│ └─────────────┬─────────────┘
│ │ <- мікрозавдання
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ <- тільки внутрішнє використання (libuv)
│ └─────────────┬─────────────┘
│ │ <- мікрозавдання
│ ┌─────────────┴─────────────┐
│ │ poll │ <- отримання нових I/O-подій, виконання I/O callbacks
│ └─────────────┬─────────────┘
│ │ <- мікрозавдання
│ ┌─────────────┴─────────────┐
│ │ check │ <- callbacks setImmediate
│ └─────────────┬─────────────┘
│ │ <- мікрозавдання
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ <- socket.on('close'), stream.on('close')
└───────────────────────────┘Timers: Виконує callbacks чий setTimeout або setInterval вже спрацював. setTimeout(fn, 0) означає "виконай у наступній фазі timers, не раніше ніж через 0мс" - а не "виконай зараз". При навантаженні реальне виконання може затриматися.
Pending callbacks: I/O callbacks відкладені з попередньої ітерації - наприклад, деякі TCP-помилки від ОС.
Poll: Серце event loop. Node.js отримує завершені I/O-події від ОС і виконує їх callbacks. Якщо черга порожня і немає зареєстрованих setImmediate callbacks, цикл зупиняється тут і чекає нових подій. Він рухається далі коли спрацьовує таймер або з'являється setImmediate.
Check: Виконуються callbacks setImmediate. Оскільки ця фаза іде одразу після poll, setImmediate всередині I/O callback завжди спрацьовує до фази timers наступного тіку.
Close callbacks: Фаза очищення. socket.destroy() запускає socket.on('close', ...) саме тут.
Пріоритет черги мікрозавдань
Між кожним переходом між фазами Node.js зупиняється і спустошує дві черги в такому порядку:
- Callbacks
process.nextTick - Callbacks Promise
.then()
console.log('1: sync');
setTimeout(() => console.log('5: timer'), 0);
setImmediate(() => console.log('6: immediate'));
Promise.resolve().then(() => console.log('3: promise'));
process.nextTick(() => console.log('2: nextTick'));
console.log('4: sync end');
// Виведення:
// 1: sync
// 4: sync end
// 2: nextTick <- спочатку черга nextTick
// 3: promise <- потім мікрозавдання Promise
// 5: timer <- фаза timers (наступний тік)
// 6: immediate <- фаза check (наступний тік)process.nextTick не належить жодній фазі. Він виконується щойно завершується поточна операція, ще до того як запуститься будь-яка фаза. Це робить його корисним для callbacks, які мають виконатися "після цього синхронного блоку, але до будь-якого I/O".
Коли що використовувати
- HTTP API і сервери: event loop обробляє тисячі паралельних з'єднань в одному потоці
- Real-time додатки з WebSockets: фаза check тримає
setImmediate-емісії швидкими - Важкі CPU-задачі (обробка зображень, криптографія): виноси в
worker_threads, не в головний потік - Ітеративні фонові задачі: використовуй
setImmediateзамістьprocess.nextTick, щоб I/O не блокувався між ітераціями
Типові помилки
Припущення що setTimeout(fn, 0) виконається одразу після поточного коду:
setTimeout(() => console.log('timer'), 0);
process.nextTick(() => console.log('nextTick'));
// Виведення: nextTick, потім timer
// nextTick спустошується ще до того як цикл дійде до фази timersБлокування циклу синхронним CPU-навантаженням:
// Заморожує всі інші запити до завершення
app.get('/compute', (req, res) => {
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i;
res.send(String(sum));
});
// Виправлення: виноси в worker_threads
const { Worker } = require('worker_threads');
app.get('/compute', (req, res) => {
const worker = new Worker('./compute-worker.js');
worker.on('message', (result) => res.send(String(result)));
});Рекурсивний process.nextTick блокує весь I/O:
// Небезпечно: все чекає поки рекурсія не завершиться
function recurse(n) {
if (n === 0) return;
process.nextTick(() => recurse(n - 1));
}
recurse(1000000); // I/O, таймери, setImmediate - все заблоковано
// Безпечно: setImmediate дає циклу дихати між ітераціями
function recurse(n) {
if (n === 0) return;
setImmediate(() => recurse(n - 1));
}Непередбачуваний порядок setImmediate і setTimeout поза I/O:
// Порядок залежить від системного часу
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Порядок гарантований
fs.readFile('/dev/null', () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate')); // завжди першим
});Це питання регулярно зустрічається на senior-інтерв'ю. Правило: всередині I/O callback setImmediate завжди виграє. Поза ним - залежить від того, чи встиг таймер спрацювати до початку поточного тіку.
Де це зустрічається в реальному коді
- Express.js: callbacks обробників маршрутів виконуються у фазі poll; запити до бази передаються libuv і повертаються в наступній ітерації poll
- Socket.io: емісії подій ставляться у чергу фази check, тримаючи затримку низькою
- Fastify: ініціалізація плагінів використовує асинхронні фази для запуску без блокування
- PM2 cluster mode: запускає один Node-процес на кожне ядро CPU, кожен зі своїм event loop
- Node.js streams: події readable/writable приходять через poll, що дозволяє backpressure без блокування
Follow-up питання
Q: Які шість фаз event loop і в якому порядку?
A: Timers, pending callbacks, idle/prepare, poll, check, close. Мікрозавдання (спочатку nextTick, потім Promise) виконуються між кожним переходом.
Q: Чому setImmediate спрацьовує перед setTimeout(fn, 0) всередині I/O callback?
A: Всередині I/O callback цикл знаходиться у фазі poll. Наступна фаза - check, де виконується setImmediate. Фаза timers приходить тільки в наступному тіку.
Q: Що станеться якщо process.nextTick рекурсивно викликає сам себе?
A: Event loop ніколи не просунеться далі. Всі I/O callbacks, таймери і setImmediate блокуються до завершення рекурсії. Документація Node.js прямо попереджає про це.
Q: Що libuv thread pool насправді обробляє?
A: Файлові операції, DNS-запити і crypto. Мережевий I/O не йде через thread pool - він використовує асинхронні API ядра напряму. За замовчуванням 4 потоки, змінюється через UV_THREADPOOL_SIZE.
Q: Чим event loop в Node відрізняється від браузерного?
A: Браузери обробляють tasks і microtasks, але не мають явних фаз як check або close. setImmediate - тільки Node. Браузери також вставляють рендеринг між батчами задач.
Q: Senior-питання: ти викликаєш setImmediate всередині poll callback. Скільки тіків пройде до його виконання?
A: Жодного зайвого тіку. Check іде за poll в тому самому тіку. Спочатку виконуються мікрозавдання між фазами, потім callback setImmediate. Якщо той callback реєструє ще один setImmediate, він виконається в check фазі наступного тіку.
Приклади
Базовий: порядок виконання async-операцій
console.log('A');
setTimeout(() => console.log('D: setTimeout'), 0);
Promise.resolve().then(() => console.log('C: Promise'));
process.nextTick(() => console.log('B: nextTick'));
console.log('E: sync end');
// Виведення:
// A
// E: sync end
// B: nextTick <- мікрозавдання, виконується до будь-якої фази
// C: Promise <- мікрозавдання, після черги nextTick
// D: setTimeout <- фаза timers, наступний тікСинхронний код завершується першим (A, E). Потім черга nextTick (B). Потім мікрозавдання Promise (C). Лише тоді event loop переходить до фази timers (D).
setImmediate vs setTimeout всередині і поза I/O
const fs = require('fs');
// Поза I/O callback - порядок непередбачуваний
setTimeout(() => console.log('outer timeout'), 0);
setImmediate(() => console.log('outer immediate'));
// Будь-який порядок можливий
// Всередині I/O callback - порядок фіксований
fs.readFile('/dev/null', () => {
setTimeout(() => console.log('inner timeout'), 0);
setImmediate(() => console.log('inner immediate'));
// Завжди: inner immediate -> inner timeout
});Поза I/O callback фаза timers могла вже пройти до реєстрації setImmediate - тому порядок залежить від часу. Всередині I/O callback цикл у фазі poll, check іде наступним. setImmediate виграє завжди.
Блокування event loop рекурсивним nextTick
// Небезпечно: весь I/O, таймери і setImmediate чекають
function recursiveNextTick(count) {
if (count === 0) return;
process.nextTick(() => recursiveNextTick(count - 1));
}
recursiveNextTick(1000000);
// Безпечно: setImmediate дозволяє циклу обробляти I/O між ітераціями
function recursiveImmediate(count) {
if (count === 0) return;
setImmediate(() => recursiveImmediate(count - 1));
}process.nextTick створений для невеликих разових відкладень всередині фази. setImmediate - правильний інструмент для фонових ітерацій без блокування сервера. Різниця в продакшені: сервер який залишається чутливим, проти сервера який ставить усі вхідні запити в чергу до кінця рекурсії.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.