lenec ru

← все посты

Node.js event loop: как работает и почему блокируется — разбор с примерами

19K

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

Порядок приоритетов:

  1. process.nextTick — выполняется первым, до любых microtasks.
  2. Promise.then / queueMicrotask — microtask queue.
  3. setTimeout(fn, 0) — фаза timers (macrotask).
  4. 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 мс.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.