Kodokon kodokon.com

Цикл событий Node: фазы и подводные камни

Изучи фазы libuv, реальную разницу между setImmediate и setTimeout и способы обнаружить заблокированный цикл событий.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

Node.js делегирует свой цикл событий библиотеке libuv, которая делит его на отдельные фазы: timers (setTimeout, setInterval), pending callbacks, idle/prepare (внутренняя), poll (ввод-вывод: сокеты, файлы), check (setImmediate) и close callbacks. На каждом обороте цикл проходит эти фазы именно в таком порядке. Деталь, которую знают немногие: между каждыми двумя колбэками Node сначала опустошает очередь process.nextTick, а затем микрозадачи промисов - и так происходит начиная с Node 11, после каждого колбэка, а не только между фазами.

JAVASCRIPT
import { readFile } from "node:fs";

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log("io timeout"), 0);
  setImmediate(() => console.log("io immediate"));
});
Порядок гарантирован только внутри колбэка ввода-вывода.

При запуске скрипта порядок между setTimeout(fn, 0) и setImmediate(fn) недетерминирован: setTimeout(fn, 0) на самом деле округляется до 1 мс, и если цикл доходит до фазы timers быстрее чем за миллисекунду, таймер ещё не готов к выполнению. А вот внутри колбэка ввода-вывода ты находишься в фазе poll: фаза check (setImmediate) всегда идёт раньше, чем возврат к фазе timers. Поэтому io immediate неизменно выводится раньше io timeout.

JAVASCRIPT
setTimeout(() => {
  process.nextTick(() => console.log("nextTick"));
  Promise.resolve().then(() => console.log("promise"));
  queueMicrotask(() => console.log("microtask"));
  console.log("sync");
}, 0);
// sync, nextTick, promise, microtask
Внутри колбэка nextTick выполняется раньше микрозадач.

Тонкая ловушка: на верхнем уровне ES-модуля этот порядок меняется на обратный - микрозадачи промисов выполняются раньше process.nextTick. Причина в том, что вычисление ES-модуля само выполняется как задание промиса, поэтому очередь микрозадач уже опустошается к моменту, когда твой синхронный код завершается. В CommonJS nextTick сохраняет свой приоритет даже на верхнем уровне. Всегда проверяй такой порядок в том формате модулей, который ты реально разворачиваешь.

Цикл событий работает в одном потоке: любое долгое синхронное вычисление - JSON.parse большого тела запроса, катастрофическое регулярное выражение, синхронное шифрование - замораживает все запросы, которые сейчас обрабатываются. В продакшене измеряй задержку цикла через monitorEventLoopDelay: p99 выше нескольких десятков миллисекунд говорит о зависании. Код ниже намеренно создаёт зависание, а затем измеряет его.

JAVASCRIPT
import { monitorEventLoopDelay }
  from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

const end = Date.now() + 200;
while (Date.now() < end) {}

setTimeout(() => {
  h.disable();
  const ms = h.max / 1e6;
  console.log(`max delay: ${ms.toFixed(1)} ms`);
}, 300);
Гистограмма показывает зависание на 200 мс.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Внутри колбэка ввода-вывода (например, fs.readFile) ты планируешь setTimeout(fn, 0) и setImmediate(fn). Что выполнится первым?
    • setTimeout, всегда
    • setImmediate, всегда
    • Порядок случайный, как и при запуске скрипта
  2. После колбэка в каком порядке Node опустошает приоритетные очереди?
    • Первыми идут микрозадачи промисов
    • Обе очереди опустошаются строго по очереди
    • Сначала опустошается очередь process.nextTick, затем микрозадачи
  3. Какой встроенный инструмент точно измеряет зависания цикла событий в продакшене?
    • console.time вокруг каждого запроса
    • monitorEventLoopDelay из node:perf_hooks
    • process.memoryUsage, вызываемый внутри setInterval
    • модуль node:trace_events