Event Loop в Node.js: архітектура, фази, черги
Event Loop — це серце асинхронності в Node.js. Якщо ви знайомі з Java, то знаєте про багатопотоковість та Thread Pool. Node.js працює інакше: замість створення окремих потоків для кожного запиту, він використовує єдиний потік і Event Loop для обробки всіх операцій.
Порівняння з Java/Spring Boot
Java/Spring Boot (традиційна модель):
- Кожен HTTP-запит обробляється окремим потоком з Thread Pool
- Якщо потік блокується на I/O операції (база даних, файл), він чекає
- Обмежена кількість потоків (наприклад, 200) = максимум 200 одночасних запитів
Node.js (асинхронна модель):
- Один основний потік (Event Loop)
- I/O операції делегуються системі (libuv), потік не блокується
- Може обробляти тисячі одночасних підключень на одному потоці
// Java - блокуючий виклик
String data = readFile("file.txt"); // потік чекає
System.out.println(data);
// Node.js - неблокуючий виклик
fs.readFile('file.txt', (err, data) => {
console.log(data); // виконається пізніше
});
console.log('Продовжую роботу'); // виконається одразу
Архітектура Event Loop
Event Loop в Node.js базується на бібліотеці libuv, яка надає асинхронні I/O операції для різних операційних систем.
Основні компоненти:
- Call Stack — стек викликів JavaScript (як у Java)
- Node APIs — нативні асинхронні API (fs, http, timers)
- Event Loop — механізм, що координує виконання коду
- Callback Queue — черга для callback'ів
- Microtask Queue — пріоритетна черга для Promise
Фази Event Loop
Event Loop працює циклічно, проходячи через шість фаз у порядку:
┌───────────────────────────┐
┌─>│ timers │ <- setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ <- I/O callbacks (TCP errors)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ <- внутрішні операції
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ <- нові I/O події, виконання I/O callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ <- setImmediate callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ <- socket.on('close', ...)
└───────────────────────────┘
1. Фаза Timers
Виконує callback'и, заплановані через setTimeout() та setInterval().
console.log('1. Старт');
setTimeout(() => {
console.log('3. Timeout виконано');
}, 0);
console.log('2. Синхронний код');
// Вивід:
// 1. Старт
// 2. Синхронний код
// 3. Timeout виконано
Аналогія з Java: схоже на ScheduledExecutorService.schedule(), але без окремого потоку.
2. Фаза Poll
Найважливіша фаза. Тут Event Loop:
- Отримує нові I/O події
- Виконує I/O-related callbacks (крім close, timers, setImmediate)
- Може блокуватися в очікуванні нових подій
const fs = require('fs');
fs.readFile('data.txt', (err, data) => {
console.log('Файл прочитано'); // Poll phase
});
3. Фаза Check
Виконує callback'и setImmediate(). Ця функція унікальна для Node.js.
setImmediate(() => {
console.log('Immediate');
});
setTimeout(() => {
console.log('Timeout');
}, 0);
// Порядок може відрізнятися!
// У I/O циклі setImmediate завжди виконується раніше
Microtasks vs Macrotasks
Це ключова концепція для розуміння пріоритетів виконання.
Macrotasks (Task Queue):
- setTimeout / setInterval
- setImmediate
- I/O операції
Microtasks (Microtask Queue):
- Promise.then / catch / finally
- process.nextTick (найвищий пріоритет!)
- queueMicrotask
Правило виконання: після кожної macrotask Event Loop виконує ВСІ microtasks перед наступною macrotask.
console.log('1. Синхронний');
setTimeout(() => {
console.log('5. setTimeout (macrotask)');
}, 0);
Promise.resolve().then(() => {
console.log('3. Promise (microtask)');
});
process.nextTick(() => {
console.log('2. nextTick (найвищий пріоритет)');
});
setImmediate(() => {
console.log('6. setImmediate (macrotask)');
});
Promise.resolve().then(() => {
console.log('4. Promise 2 (microtask)');
});
// Вивід:
// 1. Синхронний
// 2. nextTick (найвищий пріоритет)
// 3. Promise (microtask)
// 4. Promise 2 (microtask)
// 5. setTimeout (macrotask)
// 6. setImmediate (macrotask)
process.nextTick — особливий випадок
process.nextTick() має найвищий пріоритет і виконується ДО microtasks.
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// Вивід:
// nextTick
// Promise
Увага: надмірне використання process.nextTick() може заблокувати Event Loop!
// ПОГАНО - блокує Event Loop
function recursiveNextTick() {
process.nextTick(recursiveNextTick);
}
recursiveNextTick(); // Event Loop ніколи не перейде до наступної фази
Практичний приклад з HTTP сервером
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
console.log('1. Запит отримано (Poll phase)');
// Microtask
Promise.resolve().then(() => {
console.log('2. Promise - microtask');
});
// Macrotask
setTimeout(() => {
console.log('4. setTimeout - macrotask');
}, 0);
// process.nextTick
process.nextTick(() => {
console.log('3. nextTick - найвищий пріоритет');
});
// Асинхронна I/O операція
fs.readFile('data.txt', (err, data) => {
console.log('5. Файл прочитано (Poll phase, наступна ітерація)');
res.end(data);
});
});
server.listen(3000);
Порівняння з Spring Boot @Async
У Spring Boot для асинхронності використовується анотація @Async:
// Spring Boot - окремий потік з ThreadPoolTaskExecutor
@Async
public CompletableFuture<String> processAsync() {
// Виконується в окремому потоці
return CompletableFuture.completedFuture("Done");
}
// Node.js - той самий потік, але неблокуюча операція
async function processAsync() {
// Event Loop делегує I/O, продовжує інші задачі
const result = await someAsyncOperation();
return result;
}
Візуалізація черг
┌─────────────────────────────────────────┐
│ Call Stack (основний потік) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ process.nextTick Queue (пріоритет 1) │ ← виконується завжди першою
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Microtask Queue (пріоритет 2) │ ← Promise.then/catch
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ Macrotask Queue (пріоритет 3) │ ← setTimeout, setImmediate, I/O
└─────────────────────────────────────────┘
Поширені помилки
1. Блокування Event Loop синхронним кодом:
// ПОГАНО - блокує Event Loop
const data = fs.readFileSync('large-file.txt'); // синхронна операція
// ДОБРЕ - не блокує Event Loop
fs.readFile('large-file.txt', (err, data) => {
// асинхронна операція
});
2. Нерозуміння порядку виконання:
// Код
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
// Багато хто думає: timeout, promise
// Насправді: promise, timeout (microtask має пріоритет!)
Коли використовувати що
| Механізм | Використання |
|---|---|
| setTimeout / setInterval | Затримка виконання, періодичні задачі |
| setImmediate | Виконання після завершення поточної фази Poll |
| process.nextTick | Критично важливі операції, що мають виконатися негайно (використовуйте обережно!) |
| Promise | Асинхронні операції, що повертають результат |
Практичні рекомендації
- Уникайте синхронних операцій у продакшені (readFileSync, execSync)
- Використовуйте Promise/async-await замість callback'ів
- Обмежуйте process.nextTick — він може заблокувати Event Loop
- Для CPU-intensive задач використовуйте Worker Threads або винесіть в окремий сервіс
- Моніторте Event Loop lag за допомогою бібліотек як
blockedабоevent-loop-lag
Висновки
Event Loop — це те, що робить Node.js ефективним для I/O-intensive додатків. Розуміння його роботи критично важливе для написання продуктивного коду.
Ключові відмінності від Java:
- Node.js: один потік + асинхронність = висока пропускна здатність для I/O
- Java: багато потоків + блокування = простіша модель, але менша ефективність для I/O
- Node.js погано підходить для CPU-intensive задач (криптографія, обробка зображень)
- Java/Spring Boot краще для складної бізнес-логіки з багатопотоковістю
Коментарі
Дописати коментар