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 у libuv нет: за один проход выполняются все готовые I/O-события. Зато сама poll не крутится впустую — если событий нет, она ждёт у ядра ОС ограниченное время, а затем уступает управление фазе check.
- Микрозадачи выполняются между фазами. После каждого callback’а (и каждой фазы) Node дренажирует очереди: сначала полностью
process.nextTick, затем промисы. Это отличается от браузера, где промисы и nextTick-аналоги идут одной очередью, а здесь — две разные.
Карта фаз запоминается лучше, когда видишь цикл в движении. Запусти тренажёр ниже по шагам: следи, какая фаза подсвечена на таймлайне, куда попадают setTimeout, setImmediate, nextTick и промисы, а панель Console покажет итоговый порядок вывода. Попробуй режим «Сначала предскажи» — это та же задача, что разбор ниже, но без подсказок.
1const fs = require('node:fs');23console.log('start');45setTimeout(() => {6 console.log('timer 1');7 process.nextTick(() => console.log('nextTick in timer'));8 Promise.resolve().then(() => console.log('promise in timer'));9}, 0);1011setImmediate(() => console.log('immediate'));1213fs.readFile(__filename, () => {14 console.log('io callback');15 setTimeout(() => console.log('timer inside io'), 0);16 setImmediate(() => console.log('immediate inside io'));17});1819Promise.resolve().then(() => console.log('promise 1'));20process.nextTick(() => console.log('nextTick 1'));2122console.log('end');
Главный модуль выполняется синхронно ДО входа в Event Loop.
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-бомба не даёт начаться вообще никакой фазе — ни 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 легко превратить в оружие против самого себя.
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 грузится на ~100% (цикл крутится в дренаже микроочереди), но ни один колбек фаз не выполнится. Хуже диагностики не придумать. - Ожидание «точности» таймеров.
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: чтение файла за столь короткое время обычно не успевает завершиться, очередь 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 встал в очередь раньше. Если предсказал порядок с этой оговоркой — фазы освоены.
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 ~100% одного ядра, процесс не доходит до poll/timers — не принимает соединения и не завершается сам (на SIGKILL отреагирует). Сравни поведение сwhile(true){}. Зафиксируй разницу для себя.
Что почитать
Заголовок раздела «Что почитать»- Официальный гайд: Event Loop, timers и nextTick — первоисточник по фазам.
- libuv Design Overview — документация libuv: фазы, thread pool, handles.
- Understanding the Node.js Event Loop (Mixu) — классика про то, что такое «блокировка» на практике.
- perf_hooks: monitorEventLoopDelay — API гистограммы лага.
- Node.js: Don’t Block the Event Loop — углублённое объяснение планирования работы цикла.