Event loop Node.js: фазы, микрозадачи и libuv¶
Источник: theNodeBook — Node.js Event Loop
Поведение event loop в Node.js складывается из двух слоёв: фаз цикла libuv и приоритетных очередей на стороне JavaScript. Таймеры, I/O‑колбэки, check‑колбэки, close‑колбэки, реакции промисов и колбэки process.nextTick() попадают в разные очереди и сливаются в определённых точках.
Главное правило простое. JavaScript выполняется на одном стеке вызовов. Пока стек занят, ни один поставленный в очередь колбэк не запустится. Когда стек очищается, Node опустошает высокоприоритетные очереди в заданных точках, после чего libuv проходит свои фазы. Колбэки таймеров приходят из фазы timers. Большинство колбэков завершения сокетов и файлов — через пути, связанные с poll. Колбэки setImmediate() выполняются в check. Обработчики close — позже.
Запросы вроде node.js event loop обычно требуют не словаря терминов, а порядка выполнения. Ниже — модель упорядочивания: стек, process.nextTick(), задачи промисов, фазы libuv и случай, когда setTimeout(..., 0) и setImmediate() меняются местами в зависимости от точки планирования.
Что делает event loop Node.js¶
Event loop переводит Node между выполнением JavaScript и нативными событиями готовности. Он стартует после того, как entry‑модуль отработал достаточно дальше, чтобы стек очистился. Дальше процесс живёт, пока на него ссылаются handles, requests, таймеры, сокеты, серверы, воркеры или другие активные ресурсы.
Стек вызовов¶
Прежде чем говорить об асинхронности, нужно понять противоположность — синхронный стек вызовов. Это основа выполнения JavaScript. Представьте стопку тарелок: каждый вызов функции кладёт новую «тарелку» — кадр (frame) — наверх. В кадре лежат аргументы и локальные переменные функции.
Стек вызовов — классическая структура LIFO (last in, first out): последний положенный кадр снимается первым. Когда функция завершается и возвращает управление, её кадр снимается со стека.
Проследим простой синхронный код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Вывод:
1 2 3 4 | |
Как это работает:
- Вызывается
first(). Её кадр попадает на стек.[first] first()печатаетOne.first()вызываетsecond(). Кадрsecond()кладётся сверху.[first, second]second()печатаетTwo.second()вызываетthird().[first, second, third]third()печатаетThreeи возвращается. Кадр снимается.[first, second]second()завершается.[first]- В
first()печатаетсяDone with first, функция возвращается. Стек пуст.[]
Это единственная «рабочая зона» для всего вашего JavaScript: один стек, в каждый момент времени на вершине — одна операция.
Что на самом деле значит «блокировка»¶
«Блокировка» — не абстракция. Это прямое следствие одного стека. «Заблокировать event loop» значит положить на стек функцию, которая долго не возвращается.
Пока её кадр на вершине, ничто другое не выполняется. Весь процесс ждёт.
Пример с CPU‑тяжёлой крипто‑операцией:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
setTimeout с задержкой 1 с отработает только после сообщения о завершении блокирующей операции. Почему:
setTimeoutпланирует таймер на 1000 ms — это неблокирующая операция.hashContinuously()попадает на стек.- Цикл ~5 секунд держит кадр
hashContinuouslyна вершине. - На отметке 1 с таймер «срабатывает»: колбэк попадает в очередь.
- Но event loop не может взять колбэк: стек не пуст.
- Через ~5 с цикл завершается, кадр снимается.
- Стек пуст — event loop забирает колбэк таймера и выполняет его.
Один медленный синхронный участок останавливает всё приложение. Именно это асинхронная архитектура Node и призвана смягчать — за пределами JavaScript, в libuv и ОС.
V8, libuv и биндинги Node.js¶
Как в главе про V8, «runtime Node.js» — не одна программа, а связка технологий. Их роли важно различать.
V8 — движок JavaScript¶
Про V8 подробно — в предыдущей главе. Часть читателей может открыть эту страницу напрямую, поэтому краткое напоминание здесь уместно.
V8 — open‑source движок JavaScript на C++ от Google. В аналогии с автомобилем это «двигатель» (отсюда и имя). Он исполняет ваш JavaScript:
- компилирует код через JIT в оптимизированный машинный код;
- управляет единственным стеком вызовов;
- выделяет память в heap и занимается сборкой мусора.
Граница V8: он знает JavaScript и JS‑память. Сам по себе V8 не открывает файлы, не создаёт сокеты и не ставит таймеры. setTimeout, fs.readFile, http.createServer — это API среды (браузера или Node), а не «чистого» V8.
V8 — «профессор лингвистики», который говорит только на JavaScript; для работы с ОС нужны посредники.
libuv¶
libuv — C‑библиотека для асинхронного I/O. Изначально создана для Node.js; она даёт event‑driven неблокирующую модель. Три большие зоны ответственности:
Сам event loop. Шесть фаз, о которых дальше, оркестрирует C‑код libuv. При старте процесса Node запускает этот цикл.
Абстракция ОС. Linux — epoll, macOS — kqueue, Windows — IOCP. Ядро сообщает, когда дескрипторы готовы к I/O. libuv даёт единый C API поверх разных механизмов — поэтому http.createServer ведёт себя предсказуемо на разных платформах без смены кода приложения.
Thread pool. Node часто называют «однопоточным» — это про главный JS‑поток. libuv держит небольшой пул потоков для операций, которые нельзя честно сделать полностью неблокирующими в ОС: многие файловые вызовы, часть DNS, некоторые crypto‑задачи. Рабочий поток выполняет блокирующий системный вызов и сигнализирует главному циклу, когда можно вызвать JS‑колбэк. По умолчанию в пуле 4 потока; размер меняется через UV_THREADPOOL_SIZE (до создания пула).
C++‑биндинги Node¶
Есть два мира: V8 (JavaScript) и libuv (C, дескрипторы, системные вызовы). Биндинги Node — C++‑прослойка‑переводчик.
Вызов fs.readFile('/path', callback):
- V8 начинает выполнять вызов.
- Это не чистый JS — управление уходит в C++‑функцию биндинга.
- Биндинг упаковывает путь и колбэк в request для libuv.
- libuv читает файл (часто через thread pool).
- По завершении в очередь попадает событие готовности.
- Event loop (libuv) видит событие и вызывает биндинги.
- Биндинг переводит результат в JS и ставит ваш колбэк на стек V8.
Каждая async‑операция в Node — этот маршрут: JS → C++ → libuv → ОС → обратно.
Фазы event loop Node.js¶
Полезная визуализация цикла: NodeLoops (@vagostep).
Event loop — не одна общая очередь. Это структурированный многофазный цикл. Один полный проход — tick (виток). Понимание фаз объясняет порядок async‑операций.
Обзор одного витка¶
Tick — не единица времени, а один полный проход по фазам. Длительность витка зависит от работы внутри него.
Если вы писали или играли в игры: tick похож на кадр game loop — раз за виток выполняется пакет работы.
В каждой фазе libuv смотрит очередь фазы. Если в очереди есть колбэки, они выполняются FIFO, пока очередь не опустеет или не сработает системный лимит. Затем цикл переходит к следующей фазе.
Фаза timers¶
Первая фаза витка: колбэки setTimeout() и setInterval().
Задержка — минимальное время до права на выполнение, не гарантия точной миллисекунды. Входя в фазу, цикл сравнивает время с дедлайнами таймеров.
Для большого числа таймеров libuv использует min-heap: в корне — ближайший к истечению таймер. Так быстро узнать, сколько можно «спать» до следующего срабатывания.
Min-heap даёт O(1) для «следующего» таймера, но вставка и удаление — O(log n). Массовое создание/отмена таймеров всё равно стоит денег.
Pending callbacks и внутренние фазы¶
После timers — служебные фазы, с которыми редко взаимодействуют напрямую:
- Pending callbacks — отложенные I/O‑колбэки (например, повтор при
EAGAINна TCP). - Idle, prepare — внутренняя «уборка» libuv перед poll; в JS не экспонируются.
Фаза poll¶
Poll (polling) — многократный вопрос «есть ли готовность?»: какие дескрипторы (сокеты, файлы) готовы к I/O.
Самая важная и сложная фаза. Две задачи:
- Расчёт времени ожидания и poll I/O. Цикл решает, сколько может ждать новые события, смотрит на ближайший таймер и вызывает механизм ОС (
epoll_waitи т.п.). Это единственное «блокирующее» место цикла в хорошем смысле: процесс не крутит CPU, а ждёт сигнала ядра («файл прочитан», «новое соединение»). - Очередь poll. После пробуждения выполняются колбэки большинства I/O: соединение установлено, данные с сокета, завершён
fs.readFileиз thread pool.
Если очередь poll не пуста, колбэки выполняются до опустошения. Если пуста:
- при наличии
setImmediate()poll завершается и цикл переходит в check; - иначе цикл может ждать новые I/O здесь.
Здесь же процесс может решить завершиться: нет pending I/O, активных таймеров, immediates и других handles — работа закончена.
Фаза check¶
Одна задача: колбэки setImmediate(). Нужно выполнить код сразу после обработки событий poll — используйте setImmediate.
Кейс: подтверждение заказа в приложении доставки¶
setImmediate() откладывает работу ради latency, но не даёт durability — код выполнится только пока жив процесс. Для гарантированных фоновых задач — очереди (RabbitMQ, Redis, Kafka, таблица jobs в БД) или внешний worker с ретраями. Здесь setImmediate — иллюстрация цикла, не продакшен‑паттерн.
Бэкенд доставки еды (аналог Uber Eats / Zomato):
- Подтвердить заказ — записать в основную БД (обязательный успешный I/O).
- Уведомить ресторан — отдельное действие, не должно задерживать ответ пользователю.
После успешной записи в БД уведомление ресторану можно сделать на доли секунды позже, вне той же транзакции.
Как помогает setImmediate()¶
Сохранение заказа — async I/O; колбэк выполнится в контексте poll. Внутри успешного колбэка планируем уведомление через setImmediate(). После poll цикл заходит в check и отправляет уведомление.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | |
Close callbacks¶
Финальная фаза витка — «закрывающие» события. Например, после socket.destroy() колбэк 'close' выполнится здесь.
После этого цикл проверяет, осталась ли работа. Если да — новый виток с фазы timers.
Микрозадачи, макрозадачи и nextTick¶
Шесть фаз — «магистраль». Но есть экспресс‑полоса с более высоким приоритетом — микрозадачи.
Очереди микро- и макрозадач¶
- Макрозадача (task) — колбэк в очереди одной из фаз цикла: таймер, I/O, immediate, close. За виток из каждой фазы обычно берётся порция макрозадач по правилам libuv.
- Микрозадача — колбэк во внефазовой высокоприоритетной очереди. В Node их две:
process.nextTickи Promise Jobs.
Золотое правило: после выполнения любой одной макрозадачи runtime полностью опустошает микроочереди, прежде чем брать следующую макрозадачу.
Микрозадачи могут вклиниваться между макрозадачами внутри той же фазы.
Очередь process.nextTick() — наивысший приоритет¶
Колбэки process.nextTick() выполняются сразу после того, как текущий синхронный код сошёл со стека — до перехода цикла к следующей фазе или следующей макрозадаче. Это самый агрессивный способ «встать впереди».
Сила и риск: очередь nextTick дренируется целиком перед продолжением цикла. Рекурсивный process.nextTick голодает event loop: I/O и таймеры не получают шанса.
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Будет бесконечно печатать Starvation call: …. Колбэк setTimeout не дойдёт до фазы timers.
Последовательность выполнения¶
count = 0.- Определена
starveTheLoop— пока не вызвана. setTimeout(..., 1000)ставит колбэк в очередь timers (макрозадача).- Печатается
Starting the starvation...— синхронно. - Первый вызов
starveTheLoop: печатьStarvation call: 1, в очередьnextTickкладётся сноваstarveTheLoop, кадр снимается. - Скрипт закончился, стек пуст. Перед любой фазой правило: опустошить всю очередь
nextTick. - Цикл выполняет
starveTheLoopснова и снова; каждый раз в очередь возвращается новыйnextTick. Очередь никогда не пустеет — до timers цикл не доходит. Голодание.
Очередь Promise Jobs¶
Вторая микроочередь — для промисов. Реакции .then() / .catch() / .finally() и продолжения после await планируются сюда. Приоритет ниже, чем у nextTick.
Порядок после макрозадачи:
- Выполнить текущую макрозадачу.
- Дренировать всю очередь
nextTick. - Дренировать всю очередь Promise Jobs.
- Только потом — следующая макрозадача.
await делит async‑функцию: код до await — синхронно; продолжение — как .then() в Promise Jobs после settlement промиса.
Разбор сложного порядка выполнения¶
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | |
Детерминированный вывод: 1, 9, 4, 3, 2, 5, 7, 8, 6.
- Синхронно:
1,9; async‑часть только запланирована. - Стек пуст — золотое правило, микрозадачи:
nextTick→4.- Promise Jobs →
3. - Первый виток, timers:
setTimeout(0)→2. - До poll,
readFileзавершается, макрозадача I/O →5; внутри запланированы immediate, nextTick, promise. - Макрозадача I/O закончилась — снова микрозадачи:
7, затем8. - Check:
setImmediate→6. - Виток завершён, процесс может выйти.
Не магия — фиксированные правила.
Проблемы производительности event loop¶
Понимание цикла влияет на дизайн кода и отладку под нагрузкой.
Очевидные и неочевидные блокировщики¶
Очевидно: fs.readFileSync, длинные CPU‑циклы на главном потоке.
Неочевиднее:
- Большой JSON.
JSON.parse/JSON.stringifyполностью синхронны. Пакет на десятки–сотни мегабайт может заморозить цикл на десятки–сотни миллисекунд. Для огромных payload смотрите потоковые парсеры (stream-jsonи аналоги). - Сложные регулярные выражения. Катастрофический backtracking может занять секунды и минуты на злонамеренной строке — классический вектор DoS. Тестируйте regex на «злых» входах; при необходимости — защищённые библиотеки.
Thread pool libuv (повторно)¶
Пул libuv — общий ресурс, по умолчанию 4 потока. fs.readFile и crypto.pbkdf2 с точки зрения JS async, но внутри ждут свободный поток.
Пять одновременных запросов, каждый читает файл с медленного диска и хеширует пароль:
- Первые четыре задачи занимают потоки.
- Пятый
readFileвстаёт в очередь пула. pbkdf2для первых четырёх тоже ждёт в той же очереди.
Медленный FS увеличивает latency аутентификации. При тяжёлом FS/DNS/crypto имеет смысл поднять UV_THREADPOOL_SIZE (до создания пула).
Профилирование и отладка event loop¶
Измеряйте, не угадывайте.
Метод 1 — грубая проверка latency:
1 2 3 4 5 6 7 8 9 | |
Задержка setInterval на 50+ ms намекает, что CPU был занят синхронной работой.
Метод 2 — perf_hooks.monitorEventLoopDelay:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Точнее и с меньшими накладными расходами; подходит для продакшена.
Метод 3 — async_hooks. Трассировка жизненного цикла каждого async‑ресурса. Мощно, но сложно; обычно для APM и инструментов разработки.
CPU-bound работа и worker threads¶
Иногда задача по‑настоянию CPU‑тяжёлая. Async‑обёртки не ускорят вычисление — нужно убрать работу с главного цикла.
Разгрузка через «нарезку» и setImmediate¶
Длинную работу можно дробить: обработать кусок, следующий кусок запланировать через setImmediate() — между кусками цикл обслуживает I/O.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
Приложение остаётся отзывчивым, но суммарное время вычисления не уменьшается — для ускорения нужен параллелизм.
Настоящий параллелизм: worker_threads¶
Отдельный большой раздел посвящён worker_threads. Здесь — картина целиком.
worker_threads (стабильно с Node 12) — отдельный экземпляр V8 на своём потоке со своим event loop и изолированной памятью. Это не потоки пула libuv.
Изоляция снимает классические гонки разделяемой памяти; общение — через message passing.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
1 2 3 4 5 6 7 8 9 | |
Тяжёлый цикл крутится на другом ядре; главный event loop свободен для запросов.
Модуль cluster¶
Не путать с worker_threads. cluster масштабирует весь I/O‑bound процесс (HTTP‑сервер) на несколько ядер: master слушает порт и раздаёт TCP‑соединения воркерам‑копиям приложения, у каждого свой event loop.
worker_threads — вынести одну CPU‑тяжёлую задачу с одного цикла. cluster — несколько независимых процессов для большего числа одновременных соединений. Инструменты можно комбинировать.
setTimeout и setImmediate в Node.js¶
setTimeout(..., 0) против setImmediate()¶
Классический вопрос: что выполнится раньше? Зависит от контекста.
Случай 1: из главного модуля
1 2 | |
Порядок недетерминирован. setTimeout(0) ограничен системным минимумом (часто ~1 ms). Если к моменту фазы timers прошло ≥1 ms с планирования — сначала timeout; если цикл быстро прошёл timers и дошёл до poll/check — сначала immediate. Гонка при старте процесса.
Случай 2: из I/O‑колбэка
1 2 3 4 5 6 | |
Порядок всегда: сначала Immediate, потом Timeout. I/O‑колбэк выполняется в poll; следующая фаза — check (setImmediate). Таймер дождётся следующего витка и фазы timers.
Сборка мусора и влияние на цикл¶
V8 периодически останавливает выполнение JS для GC («stop-the-world»). Даже хороший GC при высоком давлении на память может заморозить цикл на десятки–сотни миллисекунд — как синхронный код. Потоки вместо буферизации огромных файлов в память сокращают паузы GC.
Стек очищается. Node дренирует очереди с более высоким приоритетом. libuv двигается по фазам. В большинстве случаев порядок предсказуем; «странности» почти всегда сводятся к точке, где вы запланировали таймер или immediate.
Связанное чтение¶
- Предыдущая: V8 в Node.js: JIT, скрытые классы и деоптимизация
- Далее: Жизненный цикл процесса Node.js: bootstrap, сигналы и завершение