Skip to main content

Як працює 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-циклом і весь цикл фаз зупиниться

Швидкий приклад

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

  1. Callbacks process.nextTick
  2. Callbacks Promise .then()
javascript
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) виконається одразу після поточного коду:

javascript
setTimeout(() => console.log('timer'), 0); process.nextTick(() => console.log('nextTick')); // Виведення: nextTick, потім timer // nextTick спустошується ще до того як цикл дійде до фази timers

Блокування циклу синхронним CPU-навантаженням:

javascript
// Заморожує всі інші запити до завершення 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:

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

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

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 <- мікрозавдання, після черги nextTick // D: setTimeout <- фаза timers, наступний тік

Синхронний код завершується першим (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('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

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

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

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

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

Дочитали статтю?