open atlas
↑ К треку
Node.js с нуля до senior NODE · 01 · 04

Запуск рантайма и event loop

Node выполняет ваш JS на одном потоке под управлением фазового event loop из libuv; microtask'и (nextTick > Promise) опустошаются между колбэками, fs/crypto делят threadpool на 4 слота, а любая синхронная работа блокирует все параллельные запросы.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

У вашего сервиса p50 равен 3 мс, и на дашборде он выглядит простаивающим — CPU на 20%, память ровная, давления GC нет. И вдруг p99 подскакивает до 800 мс без всякого прироста трафика, а всплески совпадают со всплесками логинов. Вы добавляете поды; обрыв остаётся ровно там же. Виновник — не сеть и не база данных: это синхронный хеш bcrypt и стандартный threadpool libuv из четырёх слотов. Один поток выполняет весь ваш JavaScript, и вы тихо сериализовали каждый запрос в очередь, о существовании которой не подозревали. Решением были не дополнительные машины — а понимание того, что выполняет ваш код между node app.js и первой строкой.

От node app.js до вашей первой строки

Сначала не выполняется ничего вашего. Когда вы набираете node app.js, C++-бинарник node поднимает целый рантайм ещё до того, как существует ваш код. Он создаёт изолят V8 (изолированный экземпляр JS-движка с собственной кучей и GC) и контекст (глобальный объект и встроенные функции для этого изолята). Он передаёт V8 платформенную прослойку, чтобы V8 мог ставить задачи обратно в libuv — C-библиотеку, которой принадлежат event loop и примитивы ввода-вывода ОС. Затем libuv инициализирует loop по умолчанию. Дальше Node выполняет свой внутренний JavaScript-bootstrap — тысячи строк, которые подключают process, глобальные объекты (console, Buffer, таймеры) и загрузчики модулей (CommonJS require и ESM-загрузчик). И только после этого загружается ваш входной модуль, и его синхронный код верхнего уровня выполняется сверху вниз до конца.

Эта последняя оговорка и есть вся ментальная модель: ваш код верхнего уровня выполняется синхронно до самого конца, прежде чем сработает хоть один асинхронный колбэк. setTimeout(fn, 0) на строке 1 не выполняется на строке 1 — он регистрирует таймер и продолжает дальше. В loop входят только когда стек вызовов пуст, то есть после того, как ваш модуль завершился. Вот почему длинный синхронный блок на старте задерживает все таймеры и I/O-колбэки, что вы запланировали: все они ждут, пока стек опустеет.

console.log("1: top-level start");
setTimeout(() => console.log("4: timer"), 0);
Promise.resolve().then(() => console.log("3: promise microtask"));
console.log("2: top-level end");

// stdout:
// 1: top-level start
// 2: top-level end          ← сначала завершается весь синхронный код
// 3: promise microtask      ← microtask'и опустошаются до тика loop
// 4: timer                  ← фаза timers у loop, последней

Loop из libuv: фазы в фиксированном порядке

Как только стек пуст, Node входит в loop. У libuv нет одной недифференцированной очереди — есть упорядоченный набор фаз, и каждый тик обходит их в одной и той же последовательности, опустошая собственную очередь колбэков фазы, прежде чем двигаться дальше:

  1. timers — колбэки setTimeout/setInterval, у которых истёк порог.
  2. pending callbacks — несколько отложенных системных колбэков (например, некоторые коды ошибок TCP).
  3. idle / prepare — внутренний учёт; их вы не трогаете никогда.
  4. poll — сердце loop. Он вычисляет, на сколько блокироваться, а затем блокируется в ожидании ввода-вывода (новые соединения, данные сокета, завершённая работа fs), запуская их колбэки по мере поступления.
  5. check — здесь срабатывают колбэки setImmediate, сразу после poll.
  6. close callbackssocket.on("close") и им подобные.

Фаза poll — это место, где процесс проводит время простоя: когда делать нечего, он спит в epoll_wait (Linux) / kqueue (macOS), пока ядро не сообщит, что файловый дескриптор готов. Именно это уведомление о готовности на уровне ядра — а не threadpool — позволяет Node масштабировать десятки тысяч параллельных сетевых сокетов на одном потоке.

Почему это работает

Почему setImmediate(fn) (фаза check) иногда срабатывает раньше, а иногда позже setTimeout(fn, 0) (фаза timers) на верхнем уровне? Потому что setTimeout(fn, 0) зажимается до минимума в 1 мс, а успела ли пройти эта 1 мс к моменту, когда loop достигает фазы timers, зависит от скорости машины и от того, что ещё загружалось — поэтому порядок недетерминирован. Но внутри I/O-колбэка порядок переворачивается и становится гарантированным: вы уже прошли фазу timers, поэтому следующая достигаемая фаза — poll → check, а значит setImmediate всегда срабатывает раньше setTimeout следующего loop. Этот детерминизм и есть причина, по которой setImmediate — верный инструмент для «выполнить после текущего I/O, до новых таймеров».

Microtask’и врезаются между всем: nextTick > Promise > loop

Фазы — это макроструктура. Под ними лежат две очереди microtask’ов, которые Node опустошает между каждым колбэком и между переходами фаз — они вообще не являются фазами loop. Порядок фиксирован, и его стоит запомнить:

  1. Очередь process.nextTick (специфичная для Node, наивысший приоритет).
  2. Очередь задач Promise / queueMicrotask (microtask-очередь ECMAScript).

Обе полностью опустошаются до пустоты, прежде чем loop разрешат продвинуться. Так что глобальный приоритет таков: process.nextTick > Promise/queueMicrotask > setTimeout/setImmediate/I/O. Программа делает это наглядным:

console.log("sync");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
queueMicrotask(() => console.log("queueMicrotask"));
console.log("sync end");

// stdout (детерминирован для microtask'ов; порядок timeout/immediate может меняться местами на верхнем уровне):
// sync
// sync end
// nextTick          ← очередь nextTick опустошается первой
// promise           ← затем очередь задач Promise…
// queueMicrotask    ← …в порядке FIFO с promise-задачами
// timeout           ← и только теперь loop тикает свои фазы
// immediate

process.nextTick выполняется раньше Promise — это полезно, когда API должен отложить до «после текущей операции», вовсе не уступая управление loop (например, чтобы эмитить событие, на которое вызывающий ещё не успел подписаться). Тот же приоритет — и его опасность, разбираем ниже.

Threadpool: где «однопоточность» перестаёт быть правдой

JS выполняется на одном потоке, но несколько встроенных операций нельзя сделать неблокирующим системным вызовом, поэтому libuv отгружает их в threadpool: fs.* (файловый I/O), dns.lookup (резолвер по умолчанию, вызывающий блокирующий getaddrinfo), CPU-нагруженный crypto вроде pbkdf2/scrypt/randomBytes и сжатие zlib. Размер пула по умолчанию — 4 (UV_THREADPOOL_SIZE, задаётся вплоть до 1024, и читается лишь однажды при старте). Что важно — сетевой I/O пул не использует: сокеты идут через epoll/kqueue в фазе poll. Так что ваши 10 000 простаивающих сокетов не стоят ничего, но пять параллельных fs.readFile при пуле из четырёх означают, что пятый ждёт слота.

Выбери лучший вариант

Обработчик запроса должен хешировать пароль (CPU-нагруженно, ~150 мс) при каждом логине. Логины приходят всплесками. Как запустить хеш, чтобы loop продолжал обслуживать другие запросы под нагрузкой?

Викторина

Почему рекурсивный process.nextTick() может подвесить сервер, обрабатывающий сетевые запросы, а рекурсивный setImmediate() — нет?

Вспомните перед уходом
  1. 01
    Коллега говорит: «Node однопоточный, поэтому у нас никогда не бывает багов конкурентности, и один медленный запрос не может повлиять на другие». Где каждая половина этой фразы права, а где ошибается?
  2. 02
    Что держит процесс Node живым, что заставляет его выйти и как сюда вписывается .unref()?
Итог

Между node app.js и вашей первой строкой бинарник строит изолят и контекст V8, libuv инициализирует event loop, а внутренний JS-bootstrap Node подключает process, глобальные объекты и загрузчики модулей — и только потом загружается ваш входной модуль, и его синхронный код верхнего уровня выполняется до конца. В loop входят только когда стек вызовов пуст, и libuv тикает фиксированную последовательность фаз — timers → pending → poll (который блокируется в ожидании I/O через epoll/kqueue) → check (setImmediate) → close — опустошая собственную очередь каждой фазы. Поперёк всего этого лежат очереди microtask’ов, опустошаемые между каждым колбэком и фазой: сначала process.nextTick, затем задачи Promise/queueMicrotask, так что глобальный приоритет — nextTick > Promise > timers/immediate/I/O. Единственному JS-потоку помогает threadpool libuv размером по умолчанию 4 (UV_THREADPOOL_SIZE, максимум 1024) для fs, dns.lookup, CPU-crypto и zlib — но никогда для сетевых сокетов. Два режима отказа, которые определяют сеньорскую интуицию: блокировка loop (один синхронный 200-мс хеш или fs.readFileSync замораживает все ~100 запросов в полёте за собой) и исчерпание threadpool (5-я параллельная crypto/fs-операция при пуле из 4 встаёт в очередь — обрыв, маскирующийся под сбой сети) — плюс рекурсивный process.nextTick, который начисто голодит фазу poll, потому что его очередь опустошается до того, как loop вообще сможет продвинуться. Следите за event-loop lag (p99 > ~50–100 мс означает беду), держите JS-поток свободным и дайте пустому loop вывести процесс, пока ref’нутые handle’ы держат его живым, а .unref() отпускает.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 8 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.