Архітектура Node.js побудована на високопродуктивному симбіозі рушія виконання JavaScript Google V8 та кросплатформенної бібліотеки асинхронного вводу-виводу libuv. Сам рушій V8 є суворо однопотоковим: він оперує єдиним стеком викликів (Call Stack) та керованою купою пам'яті (Heap). Проте серверна платформа Node.js здатна паралельно обслуговувати десятки тисяч мережевих з'єднань без виділення окремого системного потоку ОС під кожен клієнтський запит.
Ця масштабованість досягається завдяки реакторному патерну (Reactor Pattern), реалізованому всередині бібліотеки libuv:
+-------------------------------------------------------------+
| Застосунок Node.js |
| (JavaScript-код: бізнес-логіка, Fastify, RSC) |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Ядро Node.js (C++ / JS Біндинги) |
| (fs, net, http, crypto, buffer, stream тощо) |
+-------------------------------------------------------------+
/ \
v v
+-----------------------+ +-----------------------+
| Google V8 | | libuv |
| - Call Stack | | - Event Loop (6 Ph) |
| - Керування купою | | - Epoll/Kqueue/IOCP |
| - Microtask Queue | | - Thread Pool (4-1024|
+-----------------------+ +-----------------------+
|
v
+--------------------------------------+
| Ядро ОС (Syscalls) |
| Неблокуюча мережа / Сокети / IPC |
+--------------------------------------+libuv абстрагує низькорівневі системні механізми мультиплексування вводу-виводу конкретних операційних систем:
Мережеві сокети, канали міжпроцесної взаємодії (IPC) та TCP/UDP з'єднання обробляються ядром операційної системи повністю неблокуючим способом. Коли надходить вхідний пакет, ядро ОС через механізм демультиплексування пробуджує системний виклик libuv (epoll_wait, kevent або GetQueuedCompletionStatusEx) та передає зареєстрований дескриптор файлу.
Проте операційні системи не підтримують асинхронний неблокуючий режим для абсолютно всіх операцій. Зокрема, виклики файлової системи стандарту POSIX (open, read, write, stat) у більшості ОС є блокуючими на рівні файлових дескрипторів. Для таких операцій, а також для важкої криптографії (crypto.pbkdf2, crypto.scrypt), компресії (zlib) та системного розпізнавання DNS через getaddrinfo (dns.lookup), libuv підтримує внутрішній пул потоків (Thread Pool).
За замовчуванням розмір пулу дорівнює 4 робочим потокам. Його місткість регулюється змінною оточення UV_THREADPOOL_SIZE (максимум до 1024 потоків), яку необхідно встановити до запуску середовища виконання Node.js.
Цикл подій (Event Loop) у libuv — це нескінченний синхронний цикл на мові C, який виконується доти, доки в процесі залишаються активні дескриптори або таймери (uv_loop_alive). Кожен повний прохід циклу називається тіком (tick).
Всередині одного тіка libuv послідовно проходить через 6 строго визначених фаз:
+------------------------------------+
| START |
+------------------------------------+
|
v
+------------------------------------+
| 1. Timers (setTimeout/Interval) | <--- [Злив мікротасок: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 2. Pending I/O Callbacks | <--- [Злив мікротасок: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 3. Idle, Prepare (internal) | <--- [Злив мікротасок: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 4. Poll (epoll_wait / I/O Read) | <--- [Злив мікротасок: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 5. Check (setImmediate) | <--- [Злив мікротасок: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 6. Close Callbacks (socket close)| <--- [Злив мікротасок: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| Перевірка loop alive? (Так -> Дія)|
+------------------------------------+perf_hooks.monitorEventLoopDelay для тригера горизонтального автоскейлінгу (HPA) в Kubernetes.UV_THREADPOOL_SIZE у мікросервісах із частим асиметричним підписуванням JWT або роздаванням статичних файлів.setImmediate, що дозволяє фазі Poll вчасно обробляти мережеві запити та зберігати мінімальний TTFB.libuv перевіряє двійкову купу (min-heap) активних таймерів setTimeout та setInterval. Якщо поточний монотонний час циклу перевищив поріг таймера, libuv викликає його колбек через V8.ECONNREFUSED).libuv, що використовуються середовищем Node.js для синхронізації внутрішнього стану перед блокуючим очікуванням.setImmediate, потік переходить у стан очікування подій ядра через системний виклик epoll_wait (або аналог).setImmediate, фаза Poll завершується негайно без переходу в тривалий сон.setImmediate(). Забезпечує виконання коду одразу після завершення фази Poll поточного тіка.uv_close (наприклад, закриття з'єднання socket.on('close', ...)).До версії Node.js 11 черги мікрозадач (process.nextTick та проміси) очищалися лише на межах переходу між фазами libuv. Це призводило до розбіжностей зі стандартом браузерного Event Loop.
Починаючи з Node.js 11+, поведінку уніфіковано зі специфікацією HTML5/ECMAScript: черги мікрозадач спустошуються негайно після завершення кожного окремого JavaScript-колбека, незалежно від активної фази libuv.
Пріоритет черг мікрозадач:
nextTickQueue (process.nextTick): завжди має абсолютний пріоритет.microtaskQueue (Promise.then(), queueMicrotask()): виконується лише тоді, коли nextTickQueue повністю порожня.Код викликає fs.promises.readFile. Створюється Promise, а параметри передаються в C++ біндинги Node.js.
Оскільки виклики файлового I/O в POSIX блокуючі, libuv пакує задачу в структуру uv_fs_t і ставить у системну чергу пулу потоків.
Вільний потік пулу виконує блокуючий системний виклик read() і зчитує байти з диска у виділений буфер пам'яті.
Потік пулу надсилає сповіщення через uv_async_send, пробуджуючи головний потік із блокування epoll_wait.
У фазі Poll головний потік отримує результат і переводить Promise у resolved. Миттєво запускається черга microtasks із кодом .then().
Для діагностики стану черги подій у реальному часі використовується клас IntervalHistogram із модуля node:perf_hooks:
import { monitorEventLoopDelay, IntervalHistogram } from 'node:perf_hooks';
import { EventEmitter } from 'node:events';
export interface EventLoopMetrics {
minMs: number;
maxMs: number;
meanMs: number;
p50Ms: number;
p90Ms: number;
p99Ms: number;
exceedsThreshold: boolean;
}
export class EventLoopMonitor extends EventEmitter {
private histogram: IntervalHistogram | null = null;
private timer: NodeJS.Timeout | null = null;
private readonly resolutionMs: number;
private readonly intervalMs: number;
private readonly warningThresholdMs: number;
constructor(options?: {
resolutionMs?: number;
intervalMs?: number;
warningThresholdMs?: number;
}) {
super();
this.resolutionMs = options?.resolutionMs ?? 10;
this.intervalMs = options?.intervalMs ?? 1000;
this.warningThresholdMs = options?.warningThresholdMs ?? 50;
}
public start(): void {
if (this.histogram) {
return;
}
// Ініціалізація вимірювання затримок з обраною роздільною здатністю
this.histogram = monitorEventLoopDelay({
resolution: this.resolutionMs,
});
this.histogram.enable();
this.timer = setInterval(() => {
this.collectMetrics();
}, this.intervalMs);
// Таймер не повинен блокувати вихід із процесу Node.js
this.timer.unref();
}
public stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.histogram) {
this.histogram.disable();
this.histogram.reset();
this.histogram = null;
}
}
private collectMetrics(): void {
if (!this.histogram) return;
// Значення monitorEventLoopDelay фіксуються в наносекундах (1e6 нс = 1 мс)
const nsToMs = 1_000_000;
const metrics: EventLoopMetrics = {
minMs: Number((this.histogram.min / nsToMs).toFixed(2)),
maxMs: Number((this.histogram.max / nsToMs).toFixed(2)),
meanMs: Number((this.histogram.mean / nsToMs).toFixed(2)),
p50Ms: Number((this.histogram.percentile(50) / nsToMs).toFixed(2)),
p90Ms: Number((this.histogram.percentile(90) / nsToMs).toFixed(2)),
p99Ms: Number((this.histogram.percentile(99) / nsToMs).toFixed(2)),
exceedsThreshold: false,
};
metrics.exceedsThreshold = metrics.p99Ms > this.warningThresholdMs;
this.emit('metrics', metrics);
if (metrics.exceedsThreshold) {
this.emit('lag-warning', {
message: `Критична затримка Event Loop P99: ${metrics.p99Ms} мс (поріг: ${this.warningThresholdMs} мс)`,
metrics,
timestamp: Date.now(),
});
}
// Скидання гістограми для чистоти вимірів у наступному вікні
this.histogram.reset();
}
}Під час обробки великих масивів даних у головному потоці необхідно розбивати синхронні цикли на кванти з передачею керування у фазу Check через setImmediate:
/**
* Виконує пакетну обробку масиву даних без голодування Event Loop.
* Передає керування через setImmediate між батчами, дозволяючи libuv
* вчасно обробляти мережевий I/O у фазі Poll.
*/
export async function processInCooperativeBatches<T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R> | R
): Promise<R[]> {
const results: R[] = [];
let index = 0;
while (index < items.length) {
const chunk = items.slice(index, index + batchSize);
// Синхронна обробка поточного батча
for (const item of chunk) {
results.push(await processor(item));
}
index += batchSize;
// Повертаємо керування Event Loop перед наступним батчем
if (index < items.length) {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
}
return results;
}Розробники часто використовують process.nextTick для відкладення операцій. Проте якщо process.nextTick викликається рекурсивно, рушій V8 нескінченно спустошує nextTickQueue. Управління ніколи не повертається до фаз libuv, що спричиняє повне зависання мережевих сокетів, таймерів та обробки HTTP-запитів (Starvation). Використовуйте setImmediate, щоб дозволити libuv обслуговувати I/O між тіками.
Виклики fs.readFileSync, fs.writeFileSync, crypto.pbkdf2Sync або парсинг велетенських JSON-рядків заморожують єдиний потік V8. Протягом усього часу виконання операції процес не здатен прийняти жодного мережевого пакету у фазі Poll, що спричиняє різкі стрибки затримки P99 для всіх клієнтів. Використовуйте потоки (fs.createReadStream), асинхронні криптографічні API або переносьте CPU-bound задачі у Worker Threads.
Метод dns.lookup використовує блокуючий виклик getaddrinfo(3), який виконується в 4 потоках libuv (UV_THREADPOOL_SIZE = 4). Під час сотень одночасних зовнішніх HTTP-запитів до нових доменів пул вичерпується миттєво. Усі операції з файлами (fs) та криптографією стають у блокуючу чергу. Застосовуйте неблокуючі резолвери з модуля node:dns/promises (dns.resolve4, dns.resolve6), які працюють асинхронно через сокети ядра без залучення пулу потоків.
Встановлення process.env.UV_THREADPOOL_SIZE = '16' усередині JS-файлу не має жодного ефекту, якщо середовище libuv уже було ініціалізовано до запуску скрипта. Змінну UV_THREADPOOL_SIZE необхідно експортувати суворо перед запуском процесу: наприклад, UV_THREADPOOL_SIZE=16 node dist/main.js або через змінні оточення в Dockerfile.
libuv перевіряє наявність активних дескрипторів (handles), таймерів або відкритих сокетів. Якщо їх немає, процес завершується з кодом 0.
Оновлюється внутрішній годинник. З двійкової купи витягуються та запускаються колбеки таймерів (setTimeout, setInterval).
Виконуються відкладені системні колбеки попередньої ітерації, такі як системні мережеві повідомлення про збої.
Внутрішні службові операції libuv для синхронізації стану перед переходом до блокуючого очікування ядра.
Розрахунок таймауту та виклик epoll_wait/kqueue/IOCP. Потік очікує події сокетів або виходить, якщо заплановано setImmediate.
Виконання функцій, зареєстрованих через setImmediate, безпосередньо після завершення фази Poll.
Сповіщення слухачів про закриття сокетів та завершення ресурсів (uv_close).