Event Loop — серце Node.js
Event Loop — серце Node.js
🎯 Мета лекції
- Зрозуміти внутрішню будову циклу подій (Event Loop) та його роль у Node.js.
- Вивчити шість фаз Event Loop та їхні специфічні завдання.
- Опанувати різницю між мікрозадачами (microtasks) та макрозадачами (macrotasks).
- Навчитися правильно використовувати
process.nextTick(),setImmediate()таsetTimeout(). - Розпізнавати та запобігати блокуванню Event Loop у продакшн-коді.
🔑 Ключові терміни
- Event Loop: механізм, що координує виконання JavaScript-коду, асинхронних операцій та callbacks.
- Фаза (Phase): окремий етап циклу подій з власною чергою callbacks.
- Мікрозадача (Microtask): високопріоритетна задача (Promise,
queueMicrotask), що виконується між фазами. - Макрозадача (Macrotask): звичайна задача (
setTimeout,setInterval), що виконується у відповідній фазі. - process.nextTick(): спеціальний механізм Node.js для виконання callback на початку наступної ітерації.
Концептуальна основа: навіщо потрібен Event Loop
У попередній лекції ми з'ясували, що Node.js працює в однопотоковій моделі для виконання JavaScript-коду. Але як один потік може одночасно обслуговувати сотні тисяч з'єднань, обробляти таймери, читати файли та реагувати на мережеві події?
Відповідь криється у циклі подій — архітектурному патерні, який дозволяє делегувати повільні операції введення/виведення операційній системі, а потім обробляти їхні результати через систему callbacks, не блокуючи основний потік виконання.
Проблема синхронного коду
Розглянемо гіпотетичний світ, де Node.js працював би синхронно:
import fs from 'node:fs';
import http from 'node:http';
http.createServer((req, res) => {
// БЛОКУВАННЯ: потік зупиняється на час читання файлу
const data: string = fs.readFileSync('./large-file.txt', 'utf8'); // 2 секунди
res.writeHead(200);
res.end(data);
}).listen(3000);
У цьому сценарії кожен запит блокує сервер на 2 секунди. Якщо надійшло 10 одночасних запитів, останній клієнт отримає відповідь через 20 секунд. При навантаженні у 1000 запитів на секунду сервер повністю втратить працездатність.
Рішення: асинхронність через Event Loop
Event Loop перетворює блокуючі операції на неблокуючі через наступний механізм:
- Делегування: запит на читання файлу передається у пул потоків libuv.
- Повернення контролю: основний потік продовжує обробку інших запитів.
- Сповіщення: коли операція завершується, результат поміщається у чергу callbacks.
- Виконання callback: Event Loop на відповідній фазі викликає зареєстрований callback з результатом.
Анатомія Event Loop: шість фаз виконання
Event Loop — це не просто безкінечний цикл. Це складна машина стану, що проходить через шість послідовних фаз на кожній ітерації. Кожна фаза має власну FIFO-чергу callbacks, які обробляються по черзі.
Загальна структура циклу
┌───────────────────────────┐
┌─>│ timers │ Фаза 1: setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ Фаза 2: відкладені I/O callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ Фаза 3: внутрішні операції Node.js
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ Фаза 4: нові I/O події, виконання callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ Фаза 5: setImmediate() callbacks
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──│ close callbacks │ Фаза 6: закриття ресурсів (socket.on('close'))
└───────────────────────────┘
queueMicrotask). Це критична особливість, яка впливає на порядок виконання коду.Фаза 1: Timers (Таймери)
Призначення: виконання callbacks, заплановані через setTimeout() та setInterval().
Таймери не гарантують точного часу виконання. Вони встановлюють мінімальний поріг затримки, після якого callback стає доступним для виконання. Фактичний момент виклику залежить від навантаження Event Loop.
console.log('Start');
setTimeout(() => {
console.log('Timeout 1: 100ms');
}, 100);
setTimeout(() => {
console.log('Timeout 2: 0ms');
}, 0);
console.log('End');
// Вивід:
// Start
// End
// Timeout 2: 0ms
// Timeout 1: 100ms
Чому setTimeout(fn, 0) не виконується негайно?
Навіть з нульовою затримкою callback потрапляє у чергу фази timers, яка обробляється на наступній ітерації Event Loop, після виконання поточного синхронного коду.
Фаза 2: Pending Callbacks (Відкладені callbacks)
Призначення: виконання callbacks для деяких системних операцій, відкладених з попередньої ітерації.
Ця фаза обробляє специфічні типи callbacks, зокрема:
- Помилки TCP-сокетів (наприклад,
ECONNREFUSEDпри спробі підключення) - Деякі внутрішні операції libuv
Більшість розробників ніколи безпосередньо не взаємодіють з цією фазою — вона призначена для внутрішніх потреб Node.js.
Фаза 3: Idle, Prepare (Внутрішні операції)
Призначення: виконання внутрішніх операцій Node.js.
Ця фаза використовується виключно платформою для підготовчих дій перед фазою poll. Прикладний код не може напряму планувати callbacks у цій фазі.
Фаза 4: Poll (Опитування I/O подій)
Призначення: очікування нових I/O подій та виконання їхніх callbacks.
Це найважливіша та найскладніша фаза Event Loop. Саме тут відбувається обробка більшості асинхронних операцій: мережевих з'єднань, читання файлів, запитів до баз даних.
Фаза poll виконує дві ключові функції:
1. Обчислення часу блокування
Event Loop визначає, скільки часу він може блокуватися в очікуванні нових I/O подій:
- Якщо черга check (setImmediate) не порожня → не блокується, переходить до наступної фази.
- Якщо є заплановані таймери → блокується максимум до моменту спрацювання найближчого таймера.
- Якщо нічого не заплановано → блокується до надходження першої події.
2. Обробка подій у черзі poll
Event Loop виконує callbacks з черги poll по порядку, поки не виникне одна з умов:
- Черга poll спустошена.
- Досягнуто системного ліміту на кількість callbacks за ітерацію.
- Настав час виконання таймера з фази timers.
import fs from 'node:fs';
console.log('1: Start');
fs.readFile('./package.json', 'utf8', (err: NodeJS.ErrnoException | null, data: string) => {
console.log('3: File read callback (poll phase)');
setTimeout(() => {
console.log('5: Nested timeout');
}, 0);
setImmediate(() => {
console.log('4: Nested setImmediate');
});
});
console.log('2: End');
// Вивід:
// 1: Start
// 2: End
// 3: File read callback (poll phase)
// 4: Nested setImmediate
// 5: Nested timeout
Аналіз порядку виконання:
- Синхронний код виконується спочатку:
1: Start,2: End. fs.readFile()делегується у пул потоків.- Коли файл прочитано, callback потрапляє у чергу poll.
- У callback додаються два нові завдання:
setTimeout(timers) таsetImmediate(check). - Після виходу з фази poll Event Loop переходить у фазу check → виконується
setImmediate. - На наступній ітерації у фазі timers виконується
setTimeout.
setImmediate() викликається всередині I/O callback (фаза poll), він завжди виконається раніше за setTimeout(fn, 0), оскільки фаза check йде безпосередньо після poll.Фаза 5: Check (Перевірка setImmediate)
Призначення: виконання callbacks, зареєстрованих через setImmediate().
Ця фаза існує для забезпечення можливості виконати код негайно після завершення фази poll, не чекаючи на наступну ітерацію циклу.
import fs from 'node:fs';
fs.readFile('./data.txt', () => {
setTimeout(() => console.log('Timeout'), 0);
setImmediate(() => console.log('Immediate'));
});
// Вивід (гарантований порядок):
// Immediate
// Timeout
Чому саме такий порядок?
Після завершення I/O callback (фаза poll) Event Loop переходить у фазу check, де негайно виконується setImmediate. Лише на наступній ітерації цикл дійде до фази timers і виконає setTimeout.
function recursiveImmediate() {
setImmediate(recursiveImmediate);
}
recursiveImmediate(); // Створює безкінечний цикл, блокуючи інші фази!
Фаза 6: Close Callbacks (Закриття ресурсів)
Призначення: виконання callbacks для подій закриття ресурсів.
Коли з'єднання (socket), файловий дескриптор або інший ресурс закривається раптово (через помилку або явний виклик .destroy()), його callback обробляється у цій фазі.
import net from 'node:net';
const socket = new net.Socket();
socket.on('close', () => {
console.log('Socket closed (close callbacks phase)');
});
socket.connect(9999, 'localhost', () => {
console.log('Connected');
});
// Примусове закриття через 100мс
setTimeout(() => {
socket.destroy();
}, 100);
Типові події, що обробляються у цій фазі:
socket.on('close')server.on('close')process.on('exit')(частково)
Мікрозадачі vs Макрозадачі: битва пріоритетів
Окрім шести основних фаз, Event Loop має окрему систему управління пріоритетами через мікрозадачі (microtasks) та макрозадачі (macrotasks).
Макрозадачі (Macrotasks)
Макрозадачі — це звичайні callbacks, що виконуються у відповідних фазах Event Loop:
setTimeout/setInterval→ фаза timerssetImmediate→ фаза check- I/O callbacks → фаза poll
socket.on('close')→ фаза close callbacks
Кожна фаза обробляє свою чергу макрозадач повністю (з обмеженням на кількість за ітерацію), перш ніж перейти до наступної фази.
Мікрозадачі (Microtasks)
Мікрозадачі мають вищий пріоритет і виконуються між фазами Event Loop. Черга мікрозадач завжди спустошується повністю перед переходом до наступної фази.
Джерела мікрозадач:
- Promise callbacks:
.then(),.catch(),.finally() - queueMicrotask(): явне додавання мікрозадачі
- async/await: під капотом використовує Promise
console.log('1: Sync start');
setTimeout(() => console.log('4: Timeout (macrotask)'), 0);
Promise.resolve()
.then(() => console.log('3: Promise 1 (microtask)'))
.then(() => console.log('3: Promise 2 (microtask)'));
queueMicrotask(() => console.log('3: queueMicrotask'));
console.log('2: Sync end');
// Вивід:
// 1: Sync start
// 2: Sync end
// 3: Promise 1 (microtask)
// 3: Promise 2 (microtask)
// 3: queueMicrotask
// 4: Timeout (macrotask)
Порядок виконання:
- Синхронний код виконується повністю.
- Черга мікрозадач спустошується (всі Promise callbacks та queueMicrotask).
- Перша фаза Event Loop (timers) → виконується setTimeout.
function infiniteMicrotasks() {
Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks(); // Event Loop застряне на мікрозадачах!
process.nextTick() — найвищий пріоритет у Node.js
Окрім стандартних мікрозадач, Node.js надає спеціальний механізм process.nextTick(), який має абсолютний пріоритет над усіма іншими callbacks, включаючи Promise.
Черга nextTick
process.nextTick() не є частиною стандарту ECMAScript — це специфічна для Node.js функція. Callbacks, додані через nextTick, зберігаються у окремій черзі, яка обробляється перед мікрозадачами та перед кожною фазою Event Loop.
Пріоритети виконання (від найвищого до найнижчого):
1. process.nextTick()
2. Мікрозадачі (Promise, queueMicrotask)
3. Макрозадачі (setTimeout, setImmediate, I/O)
console.log('1: Start');
setTimeout(() => console.log('5: Timeout'), 0);
Promise.resolve().then(() => console.log('4: Promise'));
queueMicrotask(() => console.log('4: queueMicrotask'));
process.nextTick(() => console.log('3: nextTick 1'));
process.nextTick(() => console.log('3: nextTick 2'));
console.log('2: End');
// Вивід:
// 1: Start
// 2: End
// 3: nextTick 1
// 3: nextTick 2
// 4: Promise
// 4: queueMicrotask
// 5: Timeout
Порядок виконання:
- Синхронний код:
1: Start,2: End. - Черга nextTick спустошується повністю:
3: nextTick 1,3: nextTick 2. - Черга мікрозадач:
4: Promise,4: queueMicrotask. - Фаза timers:
5: Timeout.
Коли використовувати process.nextTick()
✅ Правильні сценарії використання:
1. Гарантія асинхронності у API
Іноді потрібно забезпечити, щоб callback викликався завжди асинхронно, навіть якщо результат доступний синхронно:
function asyncOperation(data, callback) {
if (!data) {
// Помилка доступна синхронно, але callback має бути асинхронним
process.nextTick(() => callback(new Error('No data provided')));
return;
}
// Реальна асинхронна операція
fs.readFile(data, callback);
}
2. Виконання коду після завершення конструктора
class DataEmitter extends EventEmitter {
constructor() {
super();
// Гарантуємо, що слухачі встигнуть зареєструватися
process.nextTick(() => {
this.emit('ready');
});
}
}
const emitter = new DataEmitter();
emitter.on('ready', () => console.log('Emitter ready!')); // Встигне зареєструватися
3. Розбиття тривалих синхронних операцій
function processLargeArray(array, callback) {
if (array.length === 0) {
callback();
return;
}
// Обробляємо 100 елементів за раз
const batch = array.splice(0, 100);
batch.forEach(item => heavyComputation(item));
// Даємо Event Loop шанс обробити інші події
process.nextTick(() => processLargeArray(array, callback));
}
process.nextTick() може створити безкінечний цикл, що повністю блокує Event Loop:function dangerous() {
process.nextTick(dangerous);
}
dangerous(); // Event Loop ніколи не дійде до наступної фази!
process.maxTickDepth), але рекурсивні виклики все одно можуть призвести до голодування (starvation) інших фаз.process.nextTick() vs setImmediate(): коли використовувати кожен
Ці дві функції часто плутають, але вони мають кардинально різне призначення:
| Характеристика | process.nextTick() | setImmediate() |
|---|---|---|
| Пріоритет | Найвищий (перед усіма фазами) | Нормальний (фаза check) |
| Черга | Окрема черга nextTick | Черга фази check |
| Коли виконується | Перед кожною фазою Event Loop | У фазі check (після poll) |
| Блокування | Може блокувати наступну фазу | Не блокує інші фази |
| Рекурсія | ⚠️ Небезпечна (може блокувати Event Loop) | ✅ Безпечна (виконується по одному за ітерацію) |
let countNextTick = 0;
let countImmediate = 0;
function recurseNextTick() {
if (++countNextTick < 5) {
process.nextTick(recurseNextTick);
}
}
function recurseImmediate() {
if (++countImmediate < 5) {
setImmediate(recurseImmediate);
}
}
console.log('Start');
recurseNextTick();
recurseImmediate();
setTimeout(() => console.log('Timeout executed'), 0);
// Вивід:
// Start
// (усі 5 nextTick виконуються негайно, блокуючи інші фази)
// Timeout executed
// (setImmediate виконується по одному за ітерацію)
- Використовуйте
setImmediate()для відкладення виконання коду до наступної ітерації Event Loop. Це безпечніший варіант для більшості сценаріїв. - Використовуйте
process.nextTick()лише коли потрібно виконати код перед будь-якою іншою операцією, наприклад, для гарантії послідовності подій або завершення ініціалізації.
Візуалізація повного циклу: практичний приклад
Розглянемо комплексний приклад, що демонструє взаємодію всіх механізмів Event Loop:
import fs from 'node:fs';
console.log('1: Script start');
// Макрозадача: timers
setTimeout(() => {
console.log('5: setTimeout 0ms');
process.nextTick(() => console.log('6: nextTick inside setTimeout'));
Promise.resolve().then(() => console.log('6: Promise inside setTimeout'));
}, 0);
// Макрозадача: check
setImmediate(() => {
console.log('7: setImmediate');
});
// Мікрозадача
Promise.resolve()
.then(() => {
console.log('3: Promise 1');
process.nextTick(() => console.log('4: nextTick inside Promise'));
})
.then(() => console.log('4: Promise 2'));
// nextTick
process.nextTick(() => console.log('2: nextTick'));
// Асинхронна I/O операція (потрапить у poll phase)
fs.readFile(__filename, () => {
console.log('8: fs.readFile callback (poll phase)');
setTimeout(() => console.log('10: setTimeout inside readFile'), 0);
setImmediate(() => console.log('9: setImmediate inside readFile'));
});
console.log('1: Script end');
Покрокове пояснення:
Крок 1: Виконання синхронного коду
Весь код верхнього рівня виконується синхронно:
console.log('1: Script start')- Реєстрація
setTimeoutу черзі timers - Реєстрація
setImmediateу черзі check - Реєстрація Promise у черзі мікрозадач
- Реєстрація
process.nextTickу черзі nextTick - Початок асинхронного читання файлу
console.log('1: Script end')
Крок 2: Обробка черги nextTick
Event Loop спустошує чергу process.nextTick() перед переходом до мікрозадач:
console.log('2: nextTick')
Крок 3: Обробка черги мікрозадач
Виконуються всі Promise callbacks:
console.log('3: Promise 1')- Всередині Promise додається новий nextTick → він виконується негайно
console.log('4: nextTick inside Promise')console.log('4: Promise 2')
Крок 4: Фаза Timers
Event Loop переходить до фази timers:
console.log('5: setTimeout 0ms')- Всередині callback додаються nextTick та Promise → виконуються перед наступною фазою
console.log('6: nextTick inside setTimeout')console.log('6: Promise inside setTimeout')
Крок 5: Фаза Check
Виконується setImmediate:
console.log('7: setImmediate')
Крок 6: Повернення до фази Poll
Файл прочитано, callback виконується у фазі poll:
console.log('8: fs.readFile callback')- Всередині додаються
setTimeoutтаsetImmediate - Оскільки ми всередині I/O callback,
setImmediateвиконається раніше (фаза check йде після poll) console.log('9: setImmediate inside readFile')
Крок 7: Наступна ітерація — Timers
На новій ітерації виконується відкладений setTimeout:
console.log('10: setTimeout inside readFile')
Блокування Event Loop: проблема номер один
Найбільша загроза продуктивності Node.js-застосунків — це блокування Event Loop довготривалими синхронними операціями. Оскільки весь JavaScript-код виконується в одному потоці, будь-яка функція, що працює довше кількох мілісекунд, зупиняє обробку всіх інших запитів.
Типові причини блокування
1. Складні обчислення
// ❌ ПОГАНО: Блокує Event Loop на ~10 секунд
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
app.get('/fib/:n', (req, res) => {
const result = fibonacci(parseInt(req.params.n)); // Блокування!
res.json({ result });
});
2. Синхронні операції з файловою системою
// ❌ ПОГАНО: Блокує Event Loop на час читання файлу
const data = fs.readFileSync('./large-file.json', 'utf8');
console.log(data);
// ✅ ДОБРЕ: Неблокуюча версія
fs.readFile('./large-file.json', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
3. Синхронний парсинг великих JSON
// ❌ ПОГАНО: Парсинг 50MB JSON блокує Event Loop
app.post('/upload', (req, res) => {
const data = JSON.parse(req.body); // Може зайняти 100-500мс
processData(data);
res.send('OK');
});
// ✅ ДОБРЕ: Використовувати стрімінговий парсер
import JSONStream from 'JSONStream';
const parser = JSONStream.parse('*');
req.pipe(parser).on('data', (chunk: unknown) => {
processChunk(chunk); // Обробка частинами
});
4. Регулярні вирази на довгих рядках
// ❌ ПОГАНО: Складний regex на великому тексті
const emailRegex = /^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/;
const hugeText = '...'.repeat(1000000); // 1MB тексту
const result = hugeText.match(emailRegex); // Може зайняти секунди
// ✅ ДОБРЕ: Валідувати частинами або у Worker Thread
5. Криптографічні операції
// ❌ ПОГАНО: Синхронне хешування
import crypto from 'node:crypto';
const hash: Buffer = crypto.pbkdf2Sync('password', 'salt', 100000, 64, 'sha512'); // ~100мс
// ✅ ДОБРЕ: Асинхронна версія
crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', (err: Error | null, hash: Buffer) => {
// Не блокує Event Loop
});
Виявлення блокувань у продакшн
Метод 1: Моніторинг Event Loop Lag
Event Loop Lag — це затримка між моментом, коли callback мав бути виконаний, і моментом його фактичного виконання.
import { performance } from 'node:perf_hooks';
let lastCheck: number = performance.now();
setInterval(() => {
const now: number = performance.now();
const lag: number = now - lastCheck - 1000; // Очікувана затримка 1000мс
if (lag > 100) {
console.warn(`⚠️ Event Loop Lag detected: ${lag.toFixed(2)}ms`);
}
lastCheck = now;
}, 1000);
Метод 2: Використання бібліотеки blocked-at
npm install blocked-at
import blocked from 'blocked-at';
blocked((time: number, stack: string[]) => {
console.error(`⚠️ Event Loop blocked for ${time}ms`);
console.error('Stack trace:', stack);
}, { threshold: 50 }); // Поріг 50мс
Метод 3: Flamegraphs через clinic.js
npm install -g clinic
clinic doctor -- node app.js
# Відкрийте http://localhost:3000 та згенеруйте навантаження
# Ctrl+C для завершення
# Автоматично відкриється HTML-звіт з flamegraph
Стратегії уникнення блокувань
Стратегія 1: Розбиття на мікрозадачі через setImmediate
Довгі цикли можна розбити на частини, даючи Event Loop можливість обробити інші події:
function processHugeArray(array, callback) {
const batchSize = 1000;
let index = 0;
function processBatch() {
const endIndex = Math.min(index + batchSize, array.length);
for (; index < endIndex; index++) {
heavyComputation(array[index]);
}
if (index < array.length) {
setImmediate(processBatch); // Даємо Event Loop обробити інші події
} else {
callback();
}
}
processBatch();
}
// Використання
processHugeArray(millionItems, () => {
console.log('Processing complete!');
});
Стратегія 2: Worker Threads для CPU-інтенсивних задач
import { Worker } from 'node:worker_threads';
function runCPUIntensiveTask(data: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
const worker = new Worker('./compute-worker.js', {
workerData: data
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code: number) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}
// Використання у HTTP-handler
app.get('/compute', async (req, res) => {
try {
const result = await runCPUIntensiveTask(req.query.data);
res.json({ result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ error: message });
}
});
Файл compute-worker.js:
import { parentPort, workerData } from 'node:worker_threads';
function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result: number = fibonacci(workerData.n);
parentPort?.postMessage(result);
Стратегія 3: Child Processes для ізоляції
Для повної ізоляції важких обчислень:
import { fork, type ChildProcess } from 'node:child_process';
function computeInChildProcess(data: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
const child: ChildProcess = fork('./child-compute.js');
child.send(data);
child.on('message', (result: unknown) => {
resolve(result);
child.kill();
});
child.on('error', reject);
});
}
Стратегія 4: Пул воркерів для балансування навантаження
import { Worker } from 'node:worker_threads';
interface WorkerTask {
data: unknown;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
interface WorkerEntry {
worker: Worker;
busy: boolean;
}
class WorkerPool {
private workers: WorkerEntry[] = [];
private queue: WorkerTask[] = [];
constructor(workerScript: string, poolSize: number = 4) {
for (let i = 0; i < poolSize; i++) {
this.workers.push({
worker: new Worker(workerScript),
busy: false
});
}
}
exec(data: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
const availableWorker = this.workers.find((w) => !w.busy);
if (availableWorker) {
this.runTask(availableWorker, data, resolve, reject);
} else {
this.queue.push({ data, resolve, reject });
}
});
}
private runTask(
workerObj: WorkerEntry,
data: unknown,
resolve: (value: unknown) => void,
reject: (reason: unknown) => void
): void {
workerObj.busy = true;
workerObj.worker.once('message', (result: unknown) => {
workerObj.busy = false;
resolve(result);
// Обробити наступне завдання з черги
if (this.queue.length > 0) {
const next = this.queue.shift()!;
this.runTask(workerObj, next.data, next.resolve, next.reject);
}
});
workerObj.worker.once('error', (err: Error) => {
workerObj.busy = false;
reject(err);
});
workerObj.worker.postMessage(data);
}
}
// Використання
const pool = new WorkerPool('./worker.js', 4);
app.get('/compute', async (req, res) => {
const result = await pool.exec({ n: req.query.n });
res.json({ result });
});
Порівняння таймерів: setTimeout vs setImmediate vs process.nextTick
Підсумуємо відмінності між трьома основними механізмами відкладеного виконання:
Коли виконується: у фазі timers на наступній ітерації Event Loop.
Гарантії: мінімальна затримка 0мс, але фактичне виконання залежить від навантаження.
Переваги:
- Стандартний API (працює і в браузері)
- Предсказуваний для розробників з фронтенд-досвідом
Недоліки:
- Найнижчий пріоритет серед відкладених callbacks
- Не гарантує виконання "якомога швидше"
Приклад використання:
setTimeout(() => {
console.log('Виконається у фазі timers');
}, 0);
Коли виконується: у фазі check після фази poll.
Гарантії: якщо викликається всередині I/O callback, завжди виконається раніше за setTimeout(fn, 0).
Переваги:
- Ідеальний для виконання коду "після завершення поточної операції I/O"
- Безпечний для рекурсивних викликів (не блокує інші фази)
Недоліки:
- Специфічний для Node.js (не працює в браузері)
Приклад використання:
fs.readFile('data.txt', (err, data) => {
setImmediate(() => {
// Виконається негайно після завершення I/O
console.log('Processing data...');
});
});
Коли виконується: перед кожною фазою Event Loop та перед мікрозадачами.
Гарантії: найвищий пріоритет, виконається раніше за все інше.
Переваги:
- Гарантія виконання до будь-якої асинхронної операції
- Ідеальний для забезпечення послідовності подій
Недоліки:
- Може блокувати Event Loop при рекурсивному використанні
- Специфічний для Node.js
Приклад використання:
function emitAsync(event) {
process.nextTick(() => {
this.emit(event); // Слухачі встигнуть зареєструватися
});
}
Практичне порівняння
console.log('Start');
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
console.log('End');
// Вивід (гарантований порядок):
// Start
// End
// nextTick ← Найвищий пріоритет
// Promise ← Мікрозадача
// setTimeout ← Фаза timers
// setImmediate ← Фаза check
setTimeout vs setImmediate:Якщо обидва викликаються у головному модулі (поза I/O callback), порядок виконання не гарантований і залежить від продуктивності системи. Однак всередині I/O callback setImmediate завжди виконується першим.Повна діаграма життєвого циклу Event Loop
Для кращого розуміння взаємодії всіх компонентів розглянемо детальну діаграму повного циклу подій:
Практичні рекомендації та best practices
Правило 1: Завжди віддавайте перевагу асинхронним API
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
// ❌ ПОГАНО
const data: string = fsSync.readFileSync('./config.json', 'utf8');
const config = JSON.parse(data);
initializeApp(config);
// ✅ ДОБРЕ
fsSync.readFile('./config.json', 'utf8', (err: NodeJS.ErrnoException | null, data: string) => {
if (err) throw err;
const config = JSON.parse(data);
initializeApp(config);
});
// ✅ КРАЩЕ (сучасний синтаксис)
async function startup(): Promise<void> {
const data: string = await fs.readFile('./config.json', 'utf8');
const config = JSON.parse(data);
initializeApp(config);
}
Правило 2: Уникайте рекурсивних nextTick
import { type QueueItem } from './types';
// ❌ НЕБЕЗПЕЧНО
function processQueue(queue: QueueItem[]): void {
const item: QueueItem | undefined = queue.shift();
if (item) {
processItem(item);
process.nextTick(() => processQueue(queue)); // Може блокувати Event Loop
}
}
// ✅ БЕЗПЕЧНО
function processQueue(queue: QueueItem[]): void {
const item: QueueItem | undefined = queue.shift();
if (item) {
processItem(item);
setImmediate(() => processQueue(queue)); // Дає можливість іншим фазам виконатися
}
}
Правило 3: Моніторте Event Loop Lag у продакшн
import express, { type Request, type Response, type NextFunction } from 'express';
const app = express();
// Middleware для вимірювання lag
app.use((req: Request, res: Response, next: NextFunction) => {
const start: bigint = process.hrtime.bigint();
setImmediate(() => {
const lag: number = Number(process.hrtime.bigint() - start) / 1e6; // У мілісекундах
if (lag > 50) {
console.warn(`⚠️ High Event Loop Lag: ${lag.toFixed(2)}ms on ${req.path}`);
}
});
next();
});
app.get('/metrics', (req: Request, res: Response) => {
const usage = process.cpuUsage();
const memory = process.memoryUsage();
res.json({
eventLoopLag: `${lag.toFixed(2)}ms`,
cpuUser: `${usage.user / 1000}ms`,
cpuSystem: `${usage.system / 1000}ms`,
memoryHeapUsed: `${(memory.heapUsed / 1024 / 1024).toFixed(2)} MB`,
memoryExternal: `${(memory.external / 1024 / 1024).toFixed(2)} MB`
});
});
Правило 4: Використовуйте Worker Threads для CPU-інтенсивних задач
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
import express, { type Request, type Response } from 'express';
if (isMainThread) {
// Головний потік
function heavyComputation(data: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
const worker = new Worker(__filename, { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
});
}
// Використання
const app = express();
app.post('/process', async (req: Request, res: Response) => {
const result = await heavyComputation(req.body);
res.json({ result });
});
} else {
// Worker потік
function complexCalculation(data: { factor: number }): number {
// Тривалі обчислення тут не блокують основний Event Loop
let result = 0;
for (let i = 0; i < 1e9; i++) {
result += Math.sqrt(i) * data.factor;
}
return result;
}
const result: number = complexCalculation(workerData);
parentPort?.postMessage(result);
}
Правило 5: Обробляйте помилки у асинхронному коді
import express, { type Request, type Response, type NextFunction } from 'express';
// ❌ ПОГАНО: Необроблені Promise rejections
app.get('/user/:id', async (req: Request, res: Response) => {
const user = await db.getUserById(req.params.id); // Може викинути помилку
res.json(user);
});
// ✅ ДОБРЕ: Обробка помилок
app.get('/user/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await db.getUserById(req.params.id);
res.json(user);
} catch (error) {
next(error); // Передати в Express error handler
}
});
// ✅ КРАЩЕ: Глобальний обробник для unhandled rejections
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// В продакшн: логувати, надіслати alert, graceful shutdown
});
process.on('uncaughtException', (error: Error) => {
console.error('Uncaught Exception:', error);
// Критична помилка: graceful shutdown обов'язковий
process.exit(1);
});
Візуалізація порядку виконання: чек-лист для розробника
Коли аналізуєте порядок виконання складного асинхронного коду, використовуйте цей алгоритм:
Крок 1: Виділіть синхронний код
Весь код верхнього рівня виконується першим, зверху вниз. Функції setTimeout, setImmediate, Promise.then() лише реєструють callbacks, але не виконують їх.
console.log('A'); // ← Виконається першим
setTimeout(() => console.log('B'), 0); // ← Лише реєстрація
console.log('C'); // ← Виконається другим
Крок 2: Перевірте чергу process.nextTick
Після завершення синхронного коду завжди виконується черга process.nextTick() повністю.
process.nextTick(() => console.log('D')); // ← Виконається третім
Крок 3: Перевірте чергу мікрозадач
Після nextTick виконуються Promise callbacks та queueMicrotask.
Promise.resolve().then(() => console.log('E')); // ← Виконається четвертим
Крок 4: Визначте поточну фазу Event Loop
- Якщо ми в головному модулі → порядок
setTimeoutvssetImmediateне гарантований. - Якщо ми всередині I/O callback →
setImmediateвиконається першим.
fs.readFile('file.txt', () => {
setTimeout(() => console.log('F'), 0); // ← Виконається пізніше
setImmediate(() => console.log('G')); // ← Виконається раніше
});
Крок 5: Після кожної фази — знову nextTick та мікрозадачі
Event Loop перевіряє черги nextTick і мікрозадач між кожною фазою.
Резюме та ключові висновки
✅ Що потрібно запам'ятати
- Event Loop має 6 фаз: timers, pending callbacks, idle/prepare, poll, check, close callbacks.
- Мікрозадачі (Promise) виконуються між фазами з вищим пріоритетом за макрозадачі.
- process.nextTick() має найвищий пріоритет і може блокувати Event Loop.
- setImmediate() безпечніший за nextTick для рекурсивних викликів.
- Кожен callback має виконуватися не довше 10мс для уникнення блокування.
⚠️ Чого уникати
- Синхронних API (
fs.readFileSync,crypto.pbkdf2Sync) - Рекурсивних
process.nextTick()без обмежень - Тривалих обчислень у головному потоці
- Складних регулярних виразів на великих рядках
- Ігнорування Event Loop Lag у продакшн-середовищі
Інтерактивні запитання для самоперевірки
setTimeout виконується лише у фазі timers. Оскільки мікрозадачі обробляються перед переходом до фази timers, Promise завжди виконається раніше.fs.readFile виконується у фазі poll. Після завершення фази poll Event Loop негайно переходить у фазу check, де виконується setImmediate. Фаза timers, де виконується setTimeout, знаходиться на початку наступної ітерації циклу. Тому setImmediate всередині I/O callback завжди спрацьовує швидше.Використовуйте бібліотеку perf_hooks для вимірювання Event Loop Lag:
import { performance, PerformanceObserver, type PerformanceEntry } from 'node:perf_hooks';
const obs = new PerformanceObserver((items: { getEntries(): PerformanceEntry[] }) => {
const entry = items.getEntries()[0];
const lag: number = entry.duration;
if (lag > 100) {
console.warn(`Event Loop Lag: ${lag.toFixed(2)}ms`);
}
});
obs.observe({ entryTypes: ['measure'] });
setInterval(() => {
performance.measure('lag', 'start');
performance.mark('start');
}, 1000);
Альтернативно, інтегруйте готові рішення: prom-client (Prometheus metrics), clinic.js (діагностика), newrelic або datadog APM.
process.nextTick() його неможливо скасувати. На відміну від setTimeout (який повертає Timeout об'єкт для clearTimeout), nextTick не надає механізму скасування. Якщо потрібна можливість скасування, використовуйте setImmediate або власну систему черг із флагами стану.Наступна лекція: Модульні системи у Node.js
У наступному матеріалі ми розглянемо системи модулів CommonJS та ES Modules, імпорт/експорт, кешування модулів, циклічні залежності та сучасні підходи до організації коду у Node.js-проєктах з TypeScript.