Платформа Node.js як середовище виконання

Event Loop — серце Node.js

Цикл подій, фази Event Loop, мікрозадачі та макрозадачі, process.nextTick() та setImmediate()

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 перетворює блокуючі операції на неблокуючі через наступний механізм:

  1. Делегування: запит на читання файлу передається у пул потоків libuv.
  2. Повернення контролю: основний потік продовжує обробку інших запитів.
  3. Сповіщення: коли операція завершується, результат поміщається у чергу callbacks.
  4. Виконання callback: Event Loop на відповідній фазі викликає зареєстрований callback з результатом.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
autonumber

participant "JavaScript\n(Main Thread)" as JS #DBEAFE
participant "Event Loop" as EL #FEF3C7
participant "libuv\n(Thread Pool)" as UV #DCFCE7
participant "File System" as FS #E2E8F0

JS -> EL : fs.readFile('data.txt', callback)
EL -> UV : Делегувати читання файлу

note over JS #DCFCE7
  Потік звільнений!
  Обробляє інші запити
end note

UV -> FS : Системний виклик read()
FS --> UV : Дані прочитані
UV -> EL : Поміщає callback у чергу
EL -> JS : Викликає callback(err, data)

@enduml
Ключова відмінність: у асинхронній моделі час очікування I/O не витрачається марно. Поки одна операція очікує на диск або мережу, Event Loop обслуговує десятки інших запитів.

Анатомія 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'))
   └───────────────────────────┘
Між кожною фазою Event Loop виконує всі накопичені мікрозадачі (Promise callbacks, 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, після виконання поточного синхронного коду.

Проблема голодування (starvation): якщо у фазі timers накопичується багато callbacks, Event Loop може застрягти на цій фазі, затримуючи обробку нових I/O подій. Node.js обмежує кількість callbacks, що виконуються за одну ітерацію, щоб уникнути цієї проблеми.

Фаза 2: Pending Callbacks (Відкладені callbacks)

Призначення: виконання callbacks для деяких системних операцій, відкладених з попередньої ітерації.

Ця фаза обробляє специфічні типи callbacks, зокрема:

  • Помилки TCP-сокетів (наприклад, ECONNREFUSED при спробі підключення)
  • Деякі внутрішні операції libuv

Більшість розробників ніколи безпосередньо не взаємодіють з цією фазою — вона призначена для внутрішніх потреб Node.js.

Фаза 3: Idle, Prepare (Внутрішні операції)

Призначення: виконання внутрішніх операцій Node.js.

Ця фаза використовується виключно платформою для підготовчих дій перед фазою poll. Прикладний код не може напряму планувати callbacks у цій фазі.

Назва "idle" може вводити в оману — Event Loop у цій фазі не простоює. Вона виконує системні перевірки, необхідні для коректної роботи наступних фаз.

Фаза 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. Синхронний код виконується спочатку: 1: Start, 2: End.
  2. fs.readFile() делегується у пул потоків.
  3. Коли файл прочитано, callback потрапляє у чергу poll.
  4. У callback додаються два нові завдання: setTimeout (timers) та setImmediate (check).
  5. Після виходу з фази poll Event Loop переходить у фазу check → виконується setImmediate.
  6. На наступній ітерації у фазі 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.

Небезпека рекурсивного setImmediate:
function recursiveImmediate() {
  setImmediate(recursiveImmediate);
}
recursiveImmediate(); // Створює безкінечний цикл, блокуючи інші фази!
Кожна ітерація Event Loop виконуватиме лише один callback setImmediate, тому інші фази не будуть повністю заблоковані, але це все одно може призвести до деградації продуктивності.

Фаза 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') (частково)
Фаза close callbacks виконується останньою перед початком нової ітерації Event Loop. Це гарантує, що ресурси коректно звільняються перед обробкою нових подій.

Мікрозадачі vs Макрозадачі: битва пріоритетів

Окрім шести основних фаз, Event Loop має окрему систему управління пріоритетами через мікрозадачі (microtasks) та макрозадачі (macrotasks).

Макрозадачі (Macrotasks)

Макрозадачі — це звичайні callbacks, що виконуються у відповідних фазах Event Loop:

  • setTimeout / setInterval → фаза timers
  • setImmediate → фаза check
  • I/O callbacks → фаза poll
  • socket.on('close') → фаза close callbacks

Кожна фаза обробляє свою чергу макрозадач повністю (з обмеженням на кількість за ітерацію), перш ніж перейти до наступної фази.

Мікрозадачі (Microtasks)

Мікрозадачі мають вищий пріоритет і виконуються між фазами Event Loop. Черга мікрозадач завжди спустошується повністю перед переходом до наступної фази.

Джерела мікрозадач:

  1. Promise callbacks: .then(), .catch(), .finally()
  2. queueMicrotask(): явне додавання мікрозадачі
  3. 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)

Порядок виконання:

  1. Синхронний код виконується повністю.
  2. Черга мікрозадач спустошується (всі Promise callbacks та queueMicrotask).
  3. Перша фаза Event Loop (timers) → виконується setTimeout.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

start

:Виконати синхронний код;

repeat
  :Обробити всі мікрозадачі\n(Promise, queueMicrotask);
  
  :Фаза 1: Timers\n(setTimeout, setInterval);
  :Обробити мікрозадачі;
  
  :Фаза 2: Pending callbacks;
  :Обробити мікрозадачі;
  
  :Фаза 3: Idle, prepare;
  :Обробити мікрозадачі;
  
  :Фаза 4: Poll\n(I/O callbacks);
  :Обробити мікрозадачі;
  
  :Фаза 5: Check\n(setImmediate);
  :Обробити мікрозадачі;
  
  :Фаза 6: Close callbacks;
  :Обробити мікрозадачі;

repeat while (Є активні операції?) is (Так)
->Ні;

stop

@enduml
Небезпека безкінечної черги мікрозадач:
function infiniteMicrotasks() {
  Promise.resolve().then(infiniteMicrotasks);
}
infiniteMicrotasks(); // Event Loop застряне на мікрозадачах!
Оскільки мікрозадачі мають найвищий пріоритет, рекурсивне додавання Promise створить безкінечний цикл, блокуючи всі фази Event Loop. Ніякі таймери, I/O події чи setImmediate не виконуватимуться.

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. Синхронний код: 1: Start, 2: End.
  2. Черга nextTick спустошується повністю: 3: nextTick 1, 3: nextTick 2.
  3. Черга мікрозадач: 4: Promise, 4: queueMicrotask.
  4. Фаза 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));
}
❌ Небезпека зловживання nextTick:Рекурсивне використання process.nextTick() може створити безкінечний цикл, що повністю блокує Event Loop:
function dangerous() {
  process.nextTick(dangerous);
}
dangerous(); // Event Loop ніколи не дійде до наступної фази!
Node.js встановлює ліміт на кількість nextTick callbacks за одну ітерацію (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');
node event-loop-demo.js
$ node event-loop-demo.js
1: Script start
1: Script end
2: nextTick (черга nextTick)
3: Promise 1 (мікрозадачі)
4: nextTick inside Promise
4: Promise 2
5: setTimeout 0ms (фаза timers)
6: nextTick inside setTimeout
6: Promise inside setTimeout
7: setImmediate (фаза check)
8: fs.readFile callback (фаза poll)
9: setImmediate inside readFile
10: setTimeout inside readFile

Покрокове пояснення:

Крок 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
clinic doctor --on-port='wrk -t4 -c100 -d10s http://localhost:3000' -- node app.js
$ clinic doctor -- node app.js
INFO Server running on :3000
WARN Detected Event Loop blocking:
Function: fibonacci (app.js:42)
Duration: 8,420ms
CPU Usage: 99.8%
✓ Report saved to .clinic/doctor-12345.html

Стратегії уникнення блокувань

Стратегія 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 });
});
Золоте правило продуктивності Node.js:Кожен callback у Event Loop має виконуватися не довше 10 мілісекунд. Якщо операція потребує більше часу — розбийте її на частини або делегуйте у Worker Thread.

Порівняння таймерів: setTimeout vs setImmediate vs process.nextTick

Підсумуємо відмінності між трьома основними механізмами відкладеного виконання:

Коли виконується: у фазі timers на наступній ітерації Event Loop.

Гарантії: мінімальна затримка 0мс, але фактичне виконання залежить від навантаження.

Переваги:

  • Стандартний API (працює і в браузері)
  • Предсказуваний для розробників з фронтенд-досвідом

Недоліки:

  • Найнижчий пріоритет серед відкладених callbacks
  • Не гарантує виконання "якомога швидше"

Приклад використання:

setTimeout(() => {
  console.log('Виконається у фазі timers');
}, 0);

Практичне порівняння

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

Для кращого розуміння взаємодії всіх компонентів розглянемо детальну діаграму повного циклу подій:

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

start

:Початок виконання скрипта;

partition "Синхронне виконання" {
  :Виконати весь код верхнього рівня;
  note right
    Створюються черги:
    - nextTick queue
    - Microtask queue  
    - Timer queue
    - Check queue
    - I/O callbacks queue
  end note
}

repeat

  partition "nextTick Queue" #DBEAFE {
    :Виконати всі process.nextTick();
    note right
      Найвищий пріоритет!
      Може блокувати Event Loop
    end note
  }

  partition "Microtask Queue" #E2E8F0 {
    :Виконати всі Promise callbacks;
    :Виконати всі queueMicrotask();
  }

  partition "Timers Phase" #FEF3C7 {
    :Перевірити таймери (setTimeout, setInterval);
    if (Час спливу настав?) then (Так)
      :Виконати callbacks;
      :→ nextTick queue;
      :→ Microtask queue;
    endif
  }

  partition "Pending Callbacks Phase" #DCFCE7 {
    :Виконати відкладені I/O callbacks;
    :→ nextTick queue;
    :→ Microtask queue;
  }

  partition "Idle, Prepare Phase" #E0E7FF {
    :Внутрішні операції Node.js;
    note right: Недоступна для користувача
  }

  partition "Poll Phase" #FEE2E2 {
    if (Є callbacks у черзі check?) then (Так)
      :Пропустити блокування;
    else (Ні)
      if (Є активні таймери?) then (Так)
        :Блокуватися до спливу таймера;
      else (Ні)
        :Блокуватися до нової I/O події;
      endif
    endif
    
    :Виконати I/O callbacks;
    :→ nextTick queue;
    :→ Microtask queue;
  }

  partition "Check Phase" #D1FAE5 {
    :Виконати setImmediate() callbacks;
    :→ nextTick queue;
    :→ Microtask queue;
  }

  partition "Close Callbacks Phase" #FECACA {
    :Виконати close events (socket.on('close'));
    :→ nextTick queue;
    :→ Microtask queue;
  }

repeat while (Є активні handles\nабо запити?) is (Так)
->Ні;

:process.exit();

stop

@enduml

Практичні рекомендації та 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

  • Якщо ми в головному модулі → порядок setTimeout vs setImmediate не гарантований.
  • Якщо ми всередині 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 у продакшн-середовищі

Інтерактивні запитання для самоперевірки


Наступна лекція: Модульні системи у Node.js

У наступному матеріалі ми розглянемо системи модулів CommonJS та ES Modules, імпорт/експорт, кешування модулів, циклічні залежності та сучасні підходи до організації коду у Node.js-проєктах з TypeScript.

Copyright © 2026