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-запросов.
Как устроен цикл: libuv под капотом
Заголовок раздела «Как устроен цикл: libuv под капотом»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-аналоги идут одной очередью, а здесь — две разные.
nextTick против Promise: две микроочереди
Заголовок раздела «nextTick против Promise: две микроочереди»В браузере есть только одна очередь микрозадач. В 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 легко превратить в оружие против самого себя.
setImmediate против setTimeout(…, 0)
Заголовок раздела «setImmediate против setTimeout(…, 0)»Классический вопрос собеседований. Формально оба выполнятся «почти сразу», но механика разная:
setTimeout(fn, 0)попадает в фазу timers. Минимальное реальное время до срабатывания — около 1 мс (Node ограничивает разрешение таймеров), и колбек ждёт, пока цикл дойдёт до фазы timers.setImmediate(fn)попадает в фазу check, которая идёт сразу после poll.
В главном модуле порядок недетерминирован — зависит от того, за сколько миллисекунд процесс успеет пройти первый проход цикла (если быстрее ~1 мс — выиграет immediate, иначе timeout). Но внутри колбека фазы poll — всегда immediate:
const fs = require('node:fs');
// чтение файла — это I/O, колбек выполняется в фазе pollfs.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 мс, и для «как можно скорее» нужна была отдельная фаза.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Тяжёлый синхронный код в колбеке. Цикл на 5 секунд в HTTP-хендлере блокирует все соединения, включая keep-alive. Нет «другого потока», который подберёт следующий запрос: цикл один.
- Рекурсивный nextTick.
function loop() { process.nextTick(loop) }замораживает процесс мягко: CPU будет простаивать, но ничего не обработается. Хуже диагностики не придумать. - Ожидание «точности» таймеров.
setTimeout(fn, 100)означает «не раньше 100 мс». Если цикл занят, реальное время — 100 мс + время до ближайшей фазы timers + очередь. Таймауты на здоровье соединения и таймауты бизнес-логики путают регулярно. setTimeout(fn, 0)там, где нужен nextTick/immediate. «Ноль миллисекунд» — это миф: минимум ~1 мс плюс полный проход до фазы timers. Если цель — выполнить после текущего кода, бери промис илиsetImmediate.- Забытые
.unref()на таймерах-хранителях. Таймер, interval или открытый сокет удерживают процесс живым:node script.jsне завершится. Если таймер — служебный (сбор статистики), вызывай.unref(), иначе воркер никогда не умрёт естественно. - Сравнение 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» — фазы освоены.
Thread pool: что не является «неблокирующим»
Заголовок раздела «Thread pool: что не является «неблокирующим»»Сетевой 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
Заголовок раздела «Мониторинг: event loop lag»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 сделаем в главе про продакшен.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- В каком порядке выполнятся
setTimeout(fn, 0)иsetImmediate(fn)в главном модуле? Порядок недетерминирован и зависит от того, успеет ли процесс пройти полный цикл за ~1 мс. Внутри I/O-колбека всегда immediate раньше: check идёт сразу после poll, а timers — на следующей итерации. - Чем
process.nextTickотличается от промис-микротаск? Отдельная очередь с приоритетом: пока не дренирована очередь nextTick, промисы не выполняются. Задачи, добавленные из nextTick, выполняются в той же дренаже — рекурсивный nextTick блокирует цикл полностью. - Почему тяжёлый синхронный цикл «вешает» все соединения? Event Loop один на процесс и не прерывает текущий callback. Пока выполняется цикл, ни poll, ни timers не получают управления — ни новые соединения, ни keep-alive не обрабатываются.
- Какие операции идут через thread pool libuv? Файловый I/O,
dns.lookup, криптография (pbkdf2,scrypt),zlibбез стримов. Сетевой I/O — через epoll/kqueue/IOCP, пул не трогает. Размер пула по умолчанию — 4, меняется черезUV_THREADPOOL_SIZE. - Как измерить event loop lag?
perf_hooks.monitorEventLoopDelay()— гистограмма задержек с перцентилями; альтернатива — самопальный таймер + setImmediate. Смотрим p50/p99, алертим на устойчивый рост. - Что такое фаза poll и почему она главная? Опрос готовых I/O-событий; выполняется большинство прикладных колбеков (БД, HTTP, файлы). Фаза ждёт события у ядра ограниченное время, затем уступает check и следующим фазам.
- Зачем существует
setImmediate, если естьsetTimeout(fn, 0)? Исторически — из-за низкого разрешения таймеров на Windows. Сейчас — семантика: immediate гарантированно выполнится в ближайшей фазе check после текущего I/O, не завися от разрешения и очереди таймеров.
Практика
Заголовок раздела «Практика»- Лаборатория порядка выполнения. Возьми блок «предскажи вывод» из этой главы, измени его: добавь
setIntervalс одним срабатыванием (timer.unref()не нужен — отмени черезclearIntervalвнутри колбека), вложи промис внутрь immediate. Записывай предсказание до запуска. Добейся трёх подряд верных предсказаний на собственных вариантах. - Датчик lag в живом сервисе. HTTP-сервер на
node:httpс endpoint’ом/slow, который делает синхронный цикл на 2 секунды. ПодключиmonitorEventLoopDelay, выводи p99 в ответ на/metrics(просто JSON). Гони 20 RPS черезautocannonна/fastи посмотри, как ведёт себя lag в момент вызовов/slow. - Исчерпание thread pool. Скрипт, который запускает 10 одновременных
pbkdf2(100k итераций) и 10 одновременных чтений большого файла, замеряя время каждой операции. Повтори сUV_THREADPOOL_SIZE=4и=16, построй таблицу. Убедись, что операции стартуют пачками по размеру пула. - nextTick-бомба (в изоляции). Напиши
function bomb() { process.nextTick(bomb) }, запусти и наблюдай черезhtop: CPU ~0%, процесс не отвечает на сигналы до… нет, на SIGKILL отреагирует. Сравни поведение сwhile(true){}. Зафиксируй разницу для себя.
Что почитать
Заголовок раздела «Что почитать»- Официальный гайд: Event Loop, timers и nextTick — первоисточник по фазам.
- libuv Design Overview — документация libuv: фазы, thread pool, handles.
- Don’t Block the Event Loop (Mixu) — классика про то, что такое «блокировка» на практике.
- perf_hooks: monitorEventLoopDelay — API гистограммы лага.
- Node.js event loop work scheduling — углублённое объяснение планирования.