Skip to main content
Практика завдань

Як працює 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 спустошує дві черги мікрозавдань — у такому порядку:

  1. Callbacks process.nextTick() (найвищий пріоритет у Node.js)
  2. 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', ...).

Порядок виконання: повний приклад

javascript
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 порядок недетермінований — залежить від того чи спрацював таймер до початку першого тіку.

Пріоритет черги мікрозавдань

javascript
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 — спустошується першим
  • setImmediate vs setTimeout(fn, 0): всередині I/O callback setImmediate завжди перший; поза ним — недетерміновано
  • Фаза poll може заблокувати цикл якщо немає роботи — це навмисно, очікування I/O
  • Блокування головного потоку (важкі CPU-цикли, fs.readFileSync) не дає event loop обробляти будь-які інші callbacks

Типові misconceptions

«setTimeout(fn, 0) виконується одразу після поточного коду.» Ні. Він планується до фази timers майбутнього тіку. process.nextTick виконується значно раніше — після поточної операції але перед будь-яким I/O або таймером.

javascript
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-операцій

javascript
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

javascript
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

javascript
// Небезпечно: рекурсивний 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 — результатом є сервер який перестає відповідати на запити до завершення обчислень.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?
Практика завдань