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

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 у libuv нет: за один проход выполняются все готовые I/O-события. Зато сама poll не крутится впустую — если событий нет, она ждёт у ядра ОС ограниченное время, а затем уступает управление фазе check.
  • Микрозадачи выполняются между фазами. После каждого callback’а (и каждой фазы) Node дренажирует очереди: сначала полностью process.nextTick, затем промисы. Это отличается от браузера, где промисы и nextTick-аналоги идут одной очередью, а здесь — две разные.

Карта фаз запоминается лучше, когда видишь цикл в движении. Запусти тренажёр ниже по шагам: следи, какая фаза подсвечена на таймлайне, куда попадают setTimeout, setImmediate, nextTick и промисы, а панель Console покажет итоговый порядок вывода. Попробуй режим «Сначала предскажи» — это та же задача, что разбор ниже, но без подсказок.

Node.js: фазы libuv
1const fs = require('node:fs');
2
3console.log('start');
4
5setTimeout(() => {
6 console.log('timer 1');
7 process.nextTick(() => console.log('nextTick in timer'));
8 Promise.resolve().then(() => console.log('promise in timer'));
9}, 0);
10
11setImmediate(() => console.log('immediate'));
12
13fs.readFile(__filename, () => {
14 console.log('io callback');
15 setTimeout(() => console.log('timer inside io'), 0);
16 setImmediate(() => console.log('immediate inside io'));
17});
18
19Promise.resolve().then(() => console.log('promise 1'));
20process.nextTick(() => console.log('nextTick 1'));
21
22console.log('end');
главный модуль (до цикла)
Call Stack
<main>
nextTick
пусто
Промисы
пусто
Очереди фаз
пусто

Главный модуль выполняется синхронно ДО входа в Event Loop.

Console
вывод появится по мере выполнения
Шаг 0 / 16

В браузере есть только одна очередь микрозадач. В 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-бомба не даёт начаться вообще никакой фазе — ни timers, ни 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 грузится на ~100% (цикл крутится в дренаже микроочереди), но ни один колбек фаз не выполнится. Хуже диагностики не придумать.
  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: чтение файла за столь короткое время обычно не успевает завершиться, очередь poll пуста — и цикл сразу проваливается в check: immediate (из главного модуля). Вторая итерация: файл уже прочитан, poll отдаёт io callback; внутри него поставлены новые задачи: immediate попадёт в check текущего прохода (immediate inside io), а timer — в следующий (timer inside io). Итоговый порядок: start, end, nextTick 1, promise 1, timer 1, nextTick in timer, promise in timer, immediate, io callback, immediate inside io, timer inside io. Нюанс на грани: позиции immediate и io callback недетерминированы — на быстром диске чтение может завершиться ещё до первого прохода poll, и тогда io callback выведется раньше immediate. А вот immediate inside io main-immediate не опередит никогда: в фазе check действует FIFO, и main-immediate встал в очередь раньше. Если предсказал порядок с этой оговоркой — фазы освоены.

Сетевой 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 ~100% одного ядра, процесс не доходит до poll/timers — не принимает соединения и не завершается сам (на SIGKILL отреагирует). Сравни поведение с while(true){}. Зафиксируй разницу для себя.