Node.js event loop: как работает и почему блокируется — разбор с примерами
Event loop — сердце Node.js. Один поток обрабатывает тысячи соединений, пока каждый callback быстрый. Но стоит одному занять 200 мс — и весь сервер замирает. Разберём фазы цикла, разницу между micro- и macrotasks, и научимся находить блокировки.
Фазы event loop
Libuv (движок event loop) выполняет фазы в строгом порядке:
┌───────────────────────────┐
┌─>│ timers │ setTimeout, setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ I/O callbacks (TCP errors)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ новые I/O события, fs, net
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ close callbacks │ socket.on('close')
│ └─────────────┬─────────────┘
└─────────────────┘
Между каждой фазой Node.js выполняет все microtasks (Promise.then, queueMicrotask) и process.nextTick. Это критически важно для понимания порядка выполнения.
Microtasks vs macrotasks
Порядок приоритетов:
process.nextTick— выполняется первым, до любых microtasks.Promise.then / queueMicrotask— microtask queue.setTimeout(fn, 0)— фаза timers (macrotask).setImmediate— фаза check (macrotask).
console.log('1: sync');
setTimeout(() => console.log('5: setTimeout'), 0);
setImmediate(() => console.log('6: setImmediate'));
Promise.resolve().then(() => console.log('3: promise'));
queueMicrotask(() => console.log('4: microtask'));
process.nextTick(() => console.log('2: nextTick'));
// Вывод: 1, 2, 3, 4, 5, 6
// (порядок 5 и 6 может меняться вне I/O контекста)
Ловушка: process.nextTick в рекурсии заблокирует event loop навсегда — microtasks выполняются до перехода к следующей фазе. Используйте setImmediate для разбиения тяжёлых задач.
Что блокирует event loop
Любая синхронная операция дольше 1-2 мс — проблема:
- JSON.parse на 100 MB — ~300 мс блокировки. Все запросы ждут.
- crypto.pbkdf2Sync — синхронная версия хеширования, ~100 мс.
- RegExp с backtracking — катастрофический backtracking на злонамеренном вводе может занять секунды.
- Большие циклы — сортировка массива на миллион элементов, обход дерева.
- fs.readFileSync — блокирует на время чтения с диска.
// Блокирует event loop на ~300 мс
const data = JSON.parse(hugeString); // 100 MB строка
// Катастрофический backtracking
const evil = /^(a+)+$/.test('a'.repeat(30) + 'b'); // зависает
Диагностика блокировок
Мониторинг event loop lag — первый индикатор:
import { monitorEventLoopDelay } from 'perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
const p99 = histogram.percentile(99) / 1e6; // ns -> ms
if (p99 > 50) {
console.warn(`Event loop lag p99: ${p99.toFixed(1)} ms`);
}
histogram.reset();
}, 5000);
Инструменты для поиска причины:
- blocked-at — npm-пакет, показывает стектрейс кода, заблокировавшего loop дольше порога.
- clinic.js doctor — визуализирует event loop delay, CPU, memory на таймлайне.
- --prof — встроенный V8 profiler, генерирует лог для анализа через
--prof-process.
# clinic.js — быстрая диагностика
npx clinic doctor -- node dist/server.js
# Откроет HTML-отчёт с графиками и рекомендациями
# V8 profiler
node --prof dist/server.js
node --prof-process isolate-*.log > profile.txt
Решения: разблокируем event loop
1. worker_threads — для тяжёлых вычислений:
import { Worker } from 'worker_threads';
function parseJsonAsync(str: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const w = new Worker(
`const { parentPort, workerData } = require('worker_threads');
parentPort.postMessage(JSON.parse(workerData));`,
{ eval: true, workerData: str }
);
w.on('message', resolve);
w.on('error', reject);
});
}
2. setImmediate chunking — разбиваем цикл:
async function processLargeArray<T>(items: T[], fn: (item: T) => void) {
const CHUNK = 1000;
for (let i = 0; i < items.length; i += CHUNK) {
const chunk = items.slice(i, i + CHUNK);
chunk.forEach(fn);
// Отдаём управление event loop между чанками
await new Promise((r) => setImmediate(r));
}
}
3. Offload — используем async API:
crypto.pbkdf2вместоpbkdf2Sync— выполняется в libuv thread pool.fs.readFileвместоreadFileSync— не блокирует.- Streaming JSON-парсеры (stream-json) вместо
JSON.parseна всём файле.
Event loop — не магия, а конечный автомат с предсказуемым порядком выполнения. Мониторьте lag через monitorEventLoopDelay, не допускайте синхронных операций дольше 5 мс, и используйте worker_threads или chunking для тяжёлых задач. Здоровый event loop — это p99 lag ниже 10 мс.