Як працює Event Loop у Node.js
Event Loop (цикл подій) — механізм який дозволяє однопотоковому Node.js обробляти тисячі паралельних I/O-операцій без блокування: він делегує роботу ОС та пулу потоків libuv, а потім повертає результати до JavaScript через впорядковану послідовність фаз.
Теорія
Навіщо Node.js потрібен Event Loop
JavaScript однопотоковий — один call stack, одна операція в один момент. Без додаткового механізму один запит до бази даних або читання файлу заморозили б весь сервер до завершення операції.
Node.js вирішує це через принцип «ніколи не чекай». Замість блокування він передає I/O-роботу операційній системі або пулу потоків libuv, а event loop повертає результати до JavaScript коли вони готові.
Це одне з найпопулярніших питань на співбесідах для Node.js-розробників — інтерв'юер очікує пояснення фаз, а не просто «він обробляє асинхронний код».
Як працює Event Loop
Event loop — це C-програма всередині libuv яка постійно перевіряє: «Є робота?» Кожна ітерація називається тіком (tick). Всередині тіку callbacks обробляються у фіксованій послідовності фаз.
Між кожним переходом між фазами Node.js спустошує дві черги мікрозавдань — у такому порядку:
- Callbacks
process.nextTick()(найвищий пріоритет у Node.js) - Callbacks Promise (
.then(), розв'язанняasync/await)
Мікрозавдання завжди виконуються повністю перед початком наступної фази.
Шість фаз тіку
┌───────────────────────────┐
┌─>│ 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. Якщо немає готових callbacks, event loop може заблокуватись тут і чекати — якщо тільки:
- Є зареєстровані callbacks
setImmediate(переходить до check негайно) - Незабаром спрацює таймер (чекає до того моменту)
Фаза check: Виконує callbacks setImmediate. Завжди після I/O-подій поточного тіку — саме тому setImmediate всередині I/O callback спрацьовує перед setTimeout(fn, 0).
Фаза close callbacks: Обробляє cleanup callbacks: socket.destroy() → socket.on('close', ...).
Порядок виконання: повний приклад
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Виведення (завжди):
// immediate
// timeoutВсередині I/O callback setImmediate завжди спрацьовує перед setTimeout(fn, 0). Фаза check іде перед фазою timers у наступному тіку.
Поза I/O callback порядок недетермінований — залежить від того чи спрацював таймер до початку першого тіку.
Пріоритет черги мікрозавдань
console.log('1: sync start');
setTimeout(() => console.log('5: setTimeout'), 0);
setImmediate(() => console.log('6: setImmediate'));
Promise.resolve().then(() => console.log('3: Promise'));
process.nextTick(() => console.log('2: nextTick'));
console.log('4: sync end');
// Виведення:
// 1: sync start
// 4: sync end
// 2: nextTick ← спочатку черга nextTick
// 3: Promise ← потім черга мікрозавдань Promise
// 5: setTimeout ← фаза timers (наступний тік)
// 6: setImmediate ← фаза check (наступний тік)process.nextTick спрацьовує перед Promise — не через фазу, а тому що Node.js спустошує чергу nextTick перед чергою мікрозавдань Promise.
Ключові правила
- Event loop обробляє фази у строгому порядку: timers → pending → idle → poll → check → close
- Мікрозавдання (
nextTickта Promise) повністю виконуються між кожним переходом між фазами process.nextTickмає вищий пріоритет ніж callbacks Promise — спустошується першимsetImmediatevssetTimeout(fn, 0): всередині I/O callbacksetImmediateзавжди перший; поза ним — недетерміновано- Фаза poll може заблокувати цикл якщо немає роботи — це навмисно, очікування I/O
- Блокування головного потоку (важкі CPU-цикли,
fs.readFileSync) не дає event loop обробляти будь-які інші callbacks
Типові misconceptions
«setTimeout(fn, 0) виконується одразу після поточного коду.» Ні. Він планується до фази timers майбутнього тіку. process.nextTick виконується значно раніше — після поточної операції але перед будь-яким I/O або таймером.
setTimeout(() => console.log('timer'), 0);
process.nextTick(() => console.log('nextTick'));
// Виведення: nextTick, потім timer«setImmediate — це те саме що setTimeout(fn, 0).» Обидва відкладають виконання, але до різних фаз. setImmediate завжди спрацьовує у фазі check — після poll I/O. setTimeout(fn, 0) — у фазі timers, до poll. Всередині I/O callback setImmediate стабільно спрацьовує першим.
«async/await зупиняє весь Node.js.» await зупиняє лише поточну async-функцію. Event loop продовжує обробляти інші callbacks, I/O-події та таймери поки виконується очікувана операція.
Зв'язки з іншими концептами
Event loop — причина чому callbacks Promise виконуються перед setTimeout навіть з нульовою затримкою: Promise — мікрозавдання, таймери — макрозавдання (macrotask). Ця різниця є основою розуміння будь-якого асинхронного коду в Node.js.
В HTTP-серверах кожен вхідний запит приходить як I/O-подія у фазі poll. Весь middleware та обробники маршрутів виконуються синхронно всередині того callback — будь-який блокуючий код зупиняє всі інші запити.
worker_threads та child_process існують саме для перенесення важких CPU-задач з головного потоку event loop, зберігаючи чутливість сервера.
Приклади
Базовий: порядок читання 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
// D: setTimeoutСинхронний код виконується першим (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('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Може бути: timeout, immediate АБО immediate, timeout
// Всередині I/O callback — порядок завжди детермінований
fs.readFile('/dev/null', () => {
setTimeout(() => console.log('I/O timeout'), 0);
setImmediate(() => console.log('I/O immediate'));
// Завжди: I/O immediate, потім I/O timeout
});Поза I/O callback фаза timers могла вже пройти до реєстрації setImmediate, тому порядок залежить від системного часу. Всередині I/O callback виконання вже у фазі poll — наступна фаза check, а не timers. Тому setImmediate завжди спрацьовує першим.
Блокування event loop через рекурсивний nextTick
// Небезпечно: рекурсивний nextTick блокує весь I/O
function recursiveNextTick(count) {
if (count === 0) return;
process.nextTick(() => recursiveNextTick(count - 1));
}
recursiveNextTick(1000000);
// I/O callbacks, таймери та setImmediate заблоковані
// до завершення всіх 1,000,000 callbacks nextTick
// Безпечна альтернатива: setImmediate поступається event loop на кожній ітерації
function recursiveImmediate(count) {
if (count === 0) return;
setImmediate(() => recursiveImmediate(count - 1));
}Документація Node.js прямо попереджає про це. process.nextTick призначений для відкладення невеликих операцій всередині поточної фази — не для рекурсії. Використання setImmediate дозволяє event loop обробляти I/O між ітераціями.
Цей паттерн зустрічається в продакшені коли розробники намагаються «відкласти» важкі обчислення через nextTick — результатом є сервер який перестає відповідати на запити до завершення обчислень.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.