Архитектура Node.js построена на симбиозе высокопроизводительного движка исполнения JavaScript Google V8 и кроссплатформенной библиотеки асинхронного ввода-вывода libuv. Сам движок V8 однопоточен: он содержит единственный стек вызовов (Call Stack) и управляемую кучу памяти (Heap). Однако серверная платформа Node.js способна параллельно обрабатывать десятки тысяч сетевых соединений без создания отдельных потоков операционной системы на каждый запрос.
Это достигается благодаря реакторному паттерну (Reactor Pattern), реализованному внутри библиотеки libuv:
+-------------------------------------------------------------+
| Node.js Application |
| (JavaScript Code: Business Logic, Fastify, RSC) |
+-------------------------------------------------------------+
|
v
+-------------------------------------------------------------+
| Node.js Core (C++ / JS Bindings) |
| (fs, net, http, crypto, buffer, stream, etc.) |
+-------------------------------------------------------------+
/ \
v v
+-----------------------+ +-----------------------+
| Google V8 | | libuv |
| - Call Stack | | - Event Loop (6 Ph) |
| - Memory Heap | | - Epoll/Kqueue/IOCP |
| - Microtask Queue | | - Thread Pool (4-1024|
+-----------------------+ +-----------------------+
|
v
+--------------------------------------+
| OS Kernel (Syscalls) |
| Non-blocking Network / Sockets / 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) | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 2. Pending I/O Callbacks | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 3. Idle, Prepare (internal) | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 4. Poll (epoll_wait / I/O Read) | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 5. Check (setImmediate) | <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| 6. Close Callbacks (socket close)| <--- [Microtasks drain: nextTick, Promise]
+------------------------------------+
|
v
+------------------------------------+
| Check loop alive? (Yes -> Loop) |
+------------------------------------+libuv проверяет наличие активных дескрипторов (handles), таймеров или фоновых потоков. Если счетчик равен нулю, процесс завершается с кодом 0.
Обновляется монотонное время цикла. Извлекаются и выполняются колбэки таймеров (setTimeout, setInterval), срок которых наступил.
Выполняются отложенные колбэки операций ввода-вывода (например, специфические сетевые ошибки операционной системы).
Служебная фаза libuv для внутренней синхронизации модулей Node.js перед входом в блокирующее ожидание.
Расчет времени засыпания. Вызов epoll_wait/kqueue/IOCP. Поток ожидает события ввода-вывода или немедленно выходит, если запланированы задачи setImmediate.
Исполнение всех колбэков, зарегистрированных через setImmediate, непосредственно после завершения обработки фазы Poll.
Уведомление слушателей о закрытии дескрипторов ресурсов: сокетов, потоков и серверных соединений (uv_close).
В какой момент исполнения в Node.js 11+ опустошаются очереди process.nextTick и Promise микрозадач?
perf_hooks.monitorEventLoopDelay для триггера горизонтального автоскейлинга контейнеров в Kubernetes.UV_THREADPOOL_SIZE в микросервисах, активно подписывающих токены JWT через асимметричные ключи или отдающих локальные файлы.setImmediate, предотвращающее блокировку фазы Poll и сохраняющее минимальный Time to First Byte (TTFB).setTimeout и setInterval. Если текущее монотонное время цикла превысило пороговое значение таймера, libuv извлекает колбэк и передает управление в V8.ECONNREFUSED, некоторые платформы откладывают уведомление до этой фазы.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: очереди микрозадач опустошаются немедленно после выполнения каждого отдельного колбэка, вне зависимости от текущей фазы 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 главный поток забирает результат, вызывает C++ обертку, которая переводит Promise в статус resolved. Сразу запускается microtask queue с пользовательским кодом .then().
Для промышленной диагностики деградации цикла событий используется класс IntervalHistogram из модуля 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;
}
// Инициализация гистограммы с дискретностью resolutionMs
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();
}
}При обработке больших массивов данных в главном потоке Node.js необходимо разбивать синхронные итерации на неблокирующие кванты с передачей управления фазе check.
/**
* Выполняет пакетную обработку массива данных без голодания 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;
}Почему интенсивное использование dns.lookup в высоконагруженном Node.js сервисе приводит к зависанию файловых операций fs.readFile?
Программисты часто используют process.nextTick для отложенного запуска кода. Однако если process.nextTick вызывается рекурсивно или в цикле без завершения, движок V8 бесконечно опустошает nextTickQueue. В результате управление ни при каких условиях не возвращается в фазы libuv. Это вызывает стопроцентное зависание всех таймеров, сетевых сокетов и HTTP-соединений (Starvation).
Вместо рекурсивного process.nextTick следует использовать setImmediate, передающий управление следующей итерации Event Loop.
Использование синхронных методов fs.readFileSync, fs.writeFileSync, crypto.pbkdf2Sync или тяжелого JSON.parse на многомегабайтных строках внутри обработчиков HTTP-запросов замораживает главный поток V8. В течение всего времени выполнения парсинга или дискового чтения процесс не способен принять ни один входящий сетевой пакет в фазе Poll, что взвинчивает задержку P99 для всех клиентов сервера.
Используйте потоковые стримы (fs.createReadStream), асинхронные методы модуля crypto или выносите CPU-bound операции в Worker Threads.
Метод dns.lookup использует блокирующий системный вызов getaddrinfo(3) операционной системы, который исполняется внутри пула потоков libuv (UV_THREADPOOL_SIZE = 4). Если сервис выполняет сотни одновременных внешних запросов к новым доменным именам, пул потоков мгновенно исчерпывается. Все остальные файловые операции fs и криптографические функции становятся в глухую очередь ожидания.
Для разрешения IP-адресов без использования пула потоков libuv следует использовать нативные сетевые DNS-резолверы модуля node:dns/promises: dns.resolve4, dns.resolve6, работающие асинхронно через сокеты ядра.
Попытка изменить размер пула через инструкцию process.env.UV_THREADPOOL_SIZE = '16' внутри файла приложения не имеет никакого эффекта, если среда исполнения libuv уже была инициализирована к моменту запуска скрипта.
Параметр UV_THREADPOOL_SIZE обязан экспортироваться до старта процесса: например, UV_THREADPOOL_SIZE=16 node dist/main.js или через флаг запуска в Dockerfile.