Перейти к содержимому

Event Loop Node.js и libuv

В браузере Event Loop — это два контейнера: микротаски и макротаски. В Node.js картина богаче: под капотом крутится libuv, событийный цикл которого разбит на фазы, и в каждой фазе — своя очередь callback’ов. Это не академическая деталь: от порядка фаз зависит, обработается ли HTTP-запрос раньше таймаута, успеет ли setImmediate опередить setTimeout(..., 0), и почему process.nextTick способен «заморозить» цикл сильнее бесконечного цикла.

Краткая версия этой темы дала карту фаз; опорный первоисточник по ним — официальный гайд Node.js «Event Loop, timers и nextTick». Здесь мы разберём механику до уровня, на котором можно объяснить любой порядок вывода любого скрипта, и научимся измерять здоровье цикла в проде — потому что event loop lag — это та метрика, которая показывает проблемы раньше, чем растёт latency HTTP-запросов.

Node.js — это движок V8 (исполняет твой JS) + libuv (платформа событийного ввода-вывода: сокеты, файлы, таймеры, thread pool — её Design Overview стоит прочитать хотя бы один раз). Когда ты вызываешь fs.readFile() или http.get(), V8 отдаёт операцию libuv, регистрирует callback и продолжает исполнение синхронного кода. Когда операция завершена, libuv кладёт callback в очередь соответствующей фазы, и Event Loop, доходя до этой фазы, вытаскивает и выполняет колбеки.

Полный проход цикла (iteration) выглядит так:

┌───────────────────────────┐
┌─>│ timers │ setTimeout / setInterval (истекшие)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ системные колбеки TCP (ошибки и т.п.)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll (idle, ...) │ опрос I/O: почти все твои колбеки
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ socket.on('close')
└───────────────────────────┘

Ключевые детали, которые упускают в туториалах:

  • Poll — рабочая лошадка. Большинство твоих callback’ов (ответы БД, HTTP, файловый I/O) выполняются именно здесь. Фаза poll «ждёт» события у ядра ОС немного, чтобы не гонять пустой цикл.
  • Каждая фаза обрабатывает свою очередь до конца или до лимита. В poll есть лимит (UV_MAX_CALLBACKS), иначе один бесконечный поток I/O не давал бы дойти до check.
  • Микрозадачи выполняются между фазами. После каждого callback’а (и каждой фазы) Node дренажирует очереди: сначала полностью process.nextTick, затем промисы. Это отличается от браузера, где промисы и nextTick-аналоги идут одной очередью, а здесь — две разные.

В браузере есть только одна очередь микрозадач. В Node.js process.nextTick живёт в отдельной очереди, и она имеет приоритет над промисами: пока в очереди nextTick есть хоть одна задача, промисы не начнут выполняться.

Promise.resolve().then(() => console.log('promise 1'));
process.nextTick(() => {
console.log('nextTick 1');
process.nextTick(() => console.log('nextTick 2')); // рекурсия!
});
Promise.resolve().then(() => console.log('promise 2'));
// Вывод:
// nextTick 1
// nextTick 2
// promise 1
// promise 2

Обрати внимание на nextTick 2: задачи, добавленные изнутри nextTick, выполняются в той же дренаже — до того, как дойдёт очередь промисов. Самоподдерживающаяся очередь nextTick (функция, которая каждый раз планирует себя через nextTick) полностью блокирует цикл: ни промисы, ни фазы не получат управления. Бесконечный while (true) {} внутри nextTick — это хуже, чем бесконечный цикл в poll: хотя бы poll за один проход обрабатывает лимитированное число колбеков.

Каноническое применение nextTick — гарантировать, что callback выполнится после завершения текущего синхронного кода, но до любого I/O-колбека. API-модули так делают, чтобы события error/data не могли сработать синхронно до того, как вызывающий код навесил обработчики:

const { EventEmitter } = require('node:events');
function createResource() {
const emitter = new EventEmitter();
// если эмитить сразу — слушателей ещё нет, событие потеряется
process.nextTick(() => emitter.emit('init'));
return emitter;
}

Промисы — для твоего же кода (then, await). nextTick — для фреймворков и библиотек. В прикладном коде почти всегда выбирай промисы: nextTick легко превратить в оружие против самого себя.

Классический вопрос собеседований. Формально оба выполнятся «почти сразу», но механика разная:

  • setTimeout(fn, 0) попадает в фазу timers. Минимальное реальное время до срабатывания — около 1 мс (Node ограничивает разрешение таймеров), и колбек ждёт, пока цикл дойдёт до фазы timers.
  • setImmediate(fn) попадает в фазу check, которая идёт сразу после poll.

В главном модуле порядок недетерминирован — зависит от того, за сколько миллисекунд процесс успеет пройти первый проход цикла (если быстрее ~1 мс — выиграет immediate, иначе timeout). Но внутри колбека фазы poll — всегда immediate:

const fs = require('node:fs');
// чтение файла — это I/O, колбек выполняется в фазе poll
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Вывод всегда: immediate → timeout
// poll уже пройден, ближайшая фаза — check, timers будут следующим проходом
});

Практический вывод: если нужно выполнить что-то «сразу после текущего I/O-колбека, до таймеров» — это setImmediate. В остальном — обычные таймеры.

Деталь для гурманов: setTimeout(fn, 0) и setTimeout(fn, 1) — не одно и то же на некоторых платформах (Windows исторически имела другое разрешение). Историческая причина существования setImmediate: таймеры на Windows в старых версиях имели разрешение 15 мс, и для «как можно скорее» нужна была отдельная фаза.

  1. Тяжёлый синхронный код в колбеке. Цикл на 5 секунд в HTTP-хендлере блокирует все соединения, включая keep-alive. Нет «другого потока», который подберёт следующий запрос: цикл один.
  2. Рекурсивный nextTick. function loop() { process.nextTick(loop) } замораживает процесс мягко: CPU будет простаивать, но ничего не обработается. Хуже диагностики не придумать.
  3. Ожидание «точности» таймеров. setTimeout(fn, 100) означает «не раньше 100 мс». Если цикл занят, реальное время — 100 мс + время до ближайшей фазы timers + очередь. Таймауты на здоровье соединения и таймауты бизнес-логики путают регулярно.
  4. setTimeout(fn, 0) там, где нужен nextTick/immediate. «Ноль миллисекунд» — это миф: минимум ~1 мс плюс полный проход до фазы timers. Если цель — выполнить после текущего кода, бери промис или setImmediate.
  5. Забытые .unref() на таймерах-хранителях. Таймер, interval или открытый сокет удерживают процесс живым: node script.js не завершится. Если таймер — служебный (сбор статистики), вызывай .unref(), иначе воркер никогда не умрёт естественно.
  6. Сравнение Node и браузера по памяти. В браузере setImmediate нет (есть полифилы через MessageChannel), а очередь микрозадач одна. Код, перенесённый с фронта, может молчать о nextTick — он просто его не использует, но и не будет использовать и в Node, если не знать про него.

Закрой следующий фрагмент и рассуди вслух, в каком порядке будут строки:

const fs = require('node:fs');
console.log('start');
setTimeout(() => {
console.log('timer 1');
process.nextTick(() => console.log('nextTick in timer'));
Promise.resolve().then(() => console.log('promise in timer'));
}, 0);
setImmediate(() => console.log('immediate'));
fs.readFile(__filename, () => {
console.log('io callback');
setTimeout(() => console.log('timer inside io'), 0);
setImmediate(() => console.log('immediate inside io'));
});
Promise.resolve().then(() => console.log('promise 1'));
process.nextTick(() => console.log('nextTick 1'));
console.log('end');

Разбор (проверь себя): синхронный код — start, end. Далее дрена́ж микро: nextTick 1, promise 1. Первый проход фаз: timers — timer 1, и после этого колбека сразу микро: nextTick in timer, promise in timer. Потом poll → колбек чтения файла: io callback, внутри которого поставлены новые задачи: immediate попадёт в check текущего прохода (immediate inside io), а timer — в следующий проход (timer inside io). Первый проход заканчивается check: immediate (из главного модуля), затем… следующая итерация: timers — timer inside io, check — immediate inside io уже выполнен. Итоговый порядок: start, end, nextTick 1, promise 1, timer 1, nextTick in timer, promise in timer, io callback, immediate inside io, immediate, timer inside io. Если предсказал хотя бы позиции «immediate vs immediate inside io» — фазы освоены.

Сетевой I/O (сокеты, HTTP) Node обрабатывает через эффективные механизмы ядра ОС — epoll на Linux, kqueue на macOS, IOCP на Windows. А вот часть операций libuv бросает во внутренний пул потоков (по умолчанию 4 потока):

  • все файловые операции fs.* (кроме fs.watch);
  • crypto.pbkdf2, crypto.scrypt, часть хеширования;
  • zlib (gzip/deflate), если не используются стримы;
  • dns.lookup (не путать с dns.resolve* — это сетевой I/O без пула).

Это важно для двух причин. Во-первых, четыре одновременных тяжёлых файловых операции исчерпают пул, и пятая встанет в очередь — твой «асинхронный» код внезапно получит очередь. Во-вторых, операции в пуле выполняются в отдельных потоках: race conditions не появляются (у каждого потока свой изолированный вызов в libuv), но упираются в CPU и диск — большой параллелизм здесь даёт деградацию, а не ускорение. Размер пула меняется через UV_THREADPOOL_SIZE (максимум 1024), но увеличивай его осознанно: каждый поток — это стек памяти.

Event loop lag — это задержка между моментом, когда таймер «должен был» сработать, и моментом, когда колбек реально выполнился. Простейший самопальный датчик — периодический таймер, который мерит собственное опоздание:

// lag-meter.js — включаем в прод-сервис на старте
let lagMs = 0;
const timer = setInterval(() => {
const start = performance.now();
// setImmediate выполнится в ближайшей фазе check —
// измеряем, насколько позже нас «достали» после таймера
setImmediate(() => {
lagMs = performance.now() - start;
});
}, 1000);
timer.unref(); // не держим процесс живым
export function getLag() {
return lagMs;
}

В эксплуатации берут готовое: perf_hooks.monitorEventLoopDelay() из стандартной библиотеки — он даёт гистограмму (p50, p99) с минимальным оверхедом:

import { monitorEventLoopDelay } from 'node:perf_hooks';
import { writeFileSync } from 'node:fs';
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
// раз в минуту сливаем перцентили в лог
setInterval(() => {
console.log(JSON.stringify({
p50: h.percentile(50) / 1e6, // наносекунды → миллисекунды
p99: h.percentile(99) / 1e6,
max: h.max / 1e6,
}));
h.reset();
}, 60_000).unref();

Ориентиры: p50 lag до 20 мс — нормально для сервиса под нагрузкой; p99 выше 100 мс — уже видно клиентам в хвостах latency; стабильные сотни миллисекунд — цикл забит синхронной работой (часто — большой JSON через JSON.parse или синхронный fs в hot path). Полноценную разводку через prom-client сделаем в главе про продакшен.

  1. В каком порядке выполнятся setTimeout(fn, 0) и setImmediate(fn) в главном модуле? Порядок недетерминирован и зависит от того, успеет ли процесс пройти полный цикл за ~1 мс. Внутри I/O-колбека всегда immediate раньше: check идёт сразу после poll, а timers — на следующей итерации.
  2. Чем process.nextTick отличается от промис-микротаск? Отдельная очередь с приоритетом: пока не дренирована очередь nextTick, промисы не выполняются. Задачи, добавленные из nextTick, выполняются в той же дренаже — рекурсивный nextTick блокирует цикл полностью.
  3. Почему тяжёлый синхронный цикл «вешает» все соединения? Event Loop один на процесс и не прерывает текущий callback. Пока выполняется цикл, ни poll, ни timers не получают управления — ни новые соединения, ни keep-alive не обрабатываются.
  4. Какие операции идут через thread pool libuv? Файловый I/O, dns.lookup, криптография (pbkdf2, scrypt), zlib без стримов. Сетевой I/O — через epoll/kqueue/IOCP, пул не трогает. Размер пула по умолчанию — 4, меняется через UV_THREADPOOL_SIZE.
  5. Как измерить event loop lag? perf_hooks.monitorEventLoopDelay() — гистограмма задержек с перцентилями; альтернатива — самопальный таймер + setImmediate. Смотрим p50/p99, алертим на устойчивый рост.
  6. Что такое фаза poll и почему она главная? Опрос готовых I/O-событий; выполняется большинство прикладных колбеков (БД, HTTP, файлы). Фаза ждёт события у ядра ограниченное время, затем уступает check и следующим фазам.
  7. Зачем существует setImmediate, если есть setTimeout(fn, 0)? Исторически — из-за низкого разрешения таймеров на Windows. Сейчас — семантика: immediate гарантированно выполнится в ближайшей фазе check после текущего I/O, не завися от разрешения и очереди таймеров.
  1. Лаборатория порядка выполнения. Возьми блок «предскажи вывод» из этой главы, измени его: добавь setInterval с одним срабатыванием (timer.unref() не нужен — отмени через clearInterval внутри колбека), вложи промис внутрь immediate. Записывай предсказание до запуска. Добейся трёх подряд верных предсказаний на собственных вариантах.
  2. Датчик lag в живом сервисе. HTTP-сервер на node:http с endpoint’ом /slow, который делает синхронный цикл на 2 секунды. Подключи monitorEventLoopDelay, выводи p99 в ответ на /metrics (просто JSON). Гони 20 RPS через autocannon на /fast и посмотри, как ведёт себя lag в момент вызовов /slow.
  3. Исчерпание thread pool. Скрипт, который запускает 10 одновременных pbkdf2 (100k итераций) и 10 одновременных чтений большого файла, замеряя время каждой операции. Повтори с UV_THREADPOOL_SIZE=4 и =16, построй таблицу. Убедись, что операции стартуют пачками по размеру пула.
  4. nextTick-бомба (в изоляции). Напиши function bomb() { process.nextTick(bomb) }, запусти и наблюдай через htop: CPU ~0%, процесс не отвечает на сигналы до… нет, на SIGKILL отреагирует. Сравни поведение с while(true){}. Зафиксируй разницу для себя.