Учебник объясняет цикл событий JavaScript одной картинкой: поток один, колбэки едут в очередь, ввод-вывод не ждёт, поэтому всё быстро. Ночью в продакшене картина другая: процессор загружен на треть, память спокойна, а девяносто девятый процентиль задержек улетел за секунду и балансировщик выводит узлы из ротации. Между книгой и аварией лежит ровно то, что упрощённая модель не рассказывает: фазы цикла, приоритеты очередей, пул нитей под файловые операции и лаг цикла как отдельная метрика. Ниже разбор реального поведения Node.js на пиках и правил, которые из этого поведения следуют.
Упрощённая модель одного потока с одной очередью и её умолчания
В учебниковой модели три утверждения: код исполняется в одном потоке, операции ввода-вывода не ждут, а регистрируют колбэк, и колбэки исполняются по очереди. На этом уровне модель честна и полезна, она объясняет, почему нельзя читать файл синхронно в обработчике запроса. Однако умолчаний в ней больше, чем самих утверждений: цикл на самом деле состоит из фаз, у микрозадач есть собственный приоритет, часть неблокирующих операций обслуживается нитями, а таймеры не гарантируют точного времени. Каждое умолчание стоит задержек именно тогда, когда трафик вырастает в разы.
Вторая беда модели в том, что она создаёт ощущение, будто медленных мест не существует. Разработчик, уверенный, что "Node.js неблокирующий", спокойно ставит синхронный разбор большого JSON в обработчик и уходит домой. Пик приходит вечером, и модель из учебника не помогает ни предсказать поведение, ни прочитать мониторинг.
Ощущение безобидности подкрепляется мелкими примерами. Синхронное чтение небольшого конфигурационного файла в стартовом коде правда безвредно, и приём переносится в обработчик запроса:
app.get("/report", (req, res) => {
const csv = fs.readFileSync("/var/reports/today.csv", "utf8");
res.send(csv);
});
На файле в несколько килобайт задержка неощутима, на отчёте в сотни мегабайт чтение занимает заметные доли секунды, и под пиком эти доли складываются с чужими. Учебниковая модель честно предупреждает про readFileSync, но на уровне картинки легко упустить, что вреден не сам метод, а его попадание в цикл событий.
Фазы цикла libuv и приоритеты микрозадач между ними
Реальный цикл это не одна очередь, а шесть фаз, и у каждой собственная очередь с дисциплиной "первым пришёл, первым ушёл": исполнение таймеров, отложенные колбэки, подготовка, опрос новых событий ввода-вывода, фаза check с обработкой setImmediate и закрытие дескрипторов. Между фазами и после каждой макрозадачи цикл сначала опустошает очередь process.nextTick, затем очередь промисов и только потом продолжает путь. Проверить порядок можно в две строки:
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("tick"));
setImmediate(() => console.log("immediate"));
В консоли будет tick, promise, immediate: микрозадачи обгоняют переход к следующей итерации цикла. Отсюда практическое следствие: рекурсивный process.nextTick, вызывающий сам себя, оттягивает цикл бесконечно, и сервер перестаёт отвечать при полностью свободном процессоре.
Таймеры вносят поправку в учебниковское "выполнится через ноль". Задержка меньше единицы приводится к одной миллисекунде, перегруженный цикл исполняет таймер позже назначенного, и setInterval постепенно накапливает дрейф. Соревнование setTimeout с нулевой задержкой против setImmediate изнутри одного цикла официально не гарантировано, порядок зависит от фазы запуска. Вывод для кода под нагрузкой прямой: таймер это просьба "позже", а не точный источник времени.
Там, где вычисление нельзя вынести в воркер, его режут на части. Функция обрабатывает сотню записей и отдаёт управление через setImmediate, цикл успевает обслужить чужие запросы между кусками:
function processChunk(items, i) {
const end = Math.min(i + 100, items.length);
for (let k = i; k < end; k++) heavy(items[k]);
if (end < items.length) setImmediate(() => processChunk(items, end));
}
Приём старый и оттого проверенный: суммарное время работы то же, а паузы раздаются ожиданиям остальных клиентов. Плата в усложнении контроля порядка, поэтому на каждое такое место нужен тест, что разрезание не перемешало обработку.
Одна тяжёлая операция останавливает очередь целиком на пике трафика
Классическая авария выглядит буднично. Эндпоинт принимает отчёт в виде большого JSON, разбирает его синхронно и отдаёт ответ. В обычный день всё мгновенно, вечерний пик приносит файл на десяток мегабайт, и разбор занимает десятки миллисекунд. Пока идёт JSON.parse, цикл стоит: новые запросы не принимаются, готовые ответы не отправляются, health check не отвечает. Загрузка процессора остаётся невысокой, ядро занято ровно одним действием. Балансировщик не дождётся проверки здоровья и выведет узел из ротации, а весь трафик переедет к соседям с тем же кодом, и avalanche начнётся.
Отдельная тонкость в том, что "медленно" здесь не означает "много тактов": разбор десятка мегабайт может занимать вполне умеренные десятки миллисекунд, но за это время через узел с задержкой в десятки микросекунд должно пройти несколько сотен запросов. Второй сценарий того же рода это тяжёлый цикл по записям, пересчёт статистики на лету, синхронная архивация. Симптом общий: у части клиентов таймауты, в журнале пусто, процессор спокоен. Правило диагностики оттуда: низкий процессор и растущий девяносто девятый процентиль почти всегда означают блокировку цикла, а не нехватку ядер.
Локализовать тяжёлое место помогает встроенный профиль процессора. Запуск с флагом --cpu-prof складывает файл с семплами стека, который открывается в инструментах разработчика как обычный профиль: сплошные длинные полосы это и есть блокировки, и видно, кто именно их создал. Готовые обёртки из экосистемы делают то же самое одной командой, но и встроенного флага хватает для поимки виновника.
Пул из четырёх нитей для файловых операций и поиска имён
Часть "неблокирующих" операций на самом деле исполняется не в цикле, а в пуле из четырёх нитей библиотеки libuv. Туда попадают файловые операции, поиск имён через dns.lookup, разминочные функции криптографии и сжатие через zlib. Четыре это умолчание, регулируемое переменной окружения UV_THREADPOOL_SIZE.
Под пиковой нагрузкой пул сам становится узким местом. Сто одновременных чтений файла выстраиваются в очередь к четырём нитям, остальные ждут, и каждый запрос получает задержку, никак не связанную с загрузкой процессора. Массовый разбор списков адресов упирается туда же, причём есть тонкость: метод dns.lookup ходит в пул и читает локальные настройки, а dns.resolve работает через сетевые вызовы напрямую, и для массовых сценариев второй заметно свободнее.
Симптом голодания пула отличается от блокировки цикла: цикл жив и отвечает на события, но файловые вызовы ждут свободную нить. Латентность растёт точечно, у операций одного типа, и на графиках видно, что страдают именно чтения с диска. Лечение в ограничении параллелизма прикладными средствами: очередь заданий, ограничитель одновременных чтений, увеличение пула как последний шаг, а не первый.
Размер пула задаётся переменной окружения до запуска процесса:
UV_THREADPOOL_SIZE=16 node server.js
Растить пул без измерений не стоит: каждая нить стоит памяти и переключений контекста, и после определённого предела выигрыш съедается планировщиком ядра. Порядок разумный: сначала ограничить параллелизм в приложении, потом измерить длину очереди к пулу и лишь затем поднимать четыре до восьми или шестнадцати.
Разгрузка через worker_threads и cluster когда цикл не справляется
Вычисления, которые нельзя разрезать на микрозадачи, выносят из цикла. Модуль worker_threads, стабилизировавшийся в ветке 12, запускает отдельный поток со собственным циклом событий, тяжёлый разбор, пересчёт отчётов и архивация уходят туда сообщением, а результат возвращается обратно:
const {Worker} = require("worker_threads");
app.post("/import", (req, res) => {
const worker = new Worker("./parse-worker.js", {
workerData: {payload: req.body}
});
worker.on("message", (result) => res.json(result));
worker.on("error", () => res.status(500).end());
});
За воркерами нужен пул с ограничением, иначе под пиком плодятся сотни потоков и машина уходит в подкачку. Писать собственный пул обычно не нужно: пакет piscina держит очередь, ограничение и передачу сообщений, приложению остаётся передать функцию и вход. Для больших файлов до воркеров лучше дойти потоками: чтение через createReadStream с обработкой по кускам держит память ровной и оставляет цикл отзывчивым, а механизм обратного давления сам ставит чтение на паузу, когда потребитель не успевает. Второй уровень масштабирования это модуль cluster: несколько процессов на одной машине делят один порт, входящие соединения распределяются между ними, и каждый процессор работает собственным циклом. Для типичного веб-сервера горизонталь внутри машины дешевле переписывания логики, потому что код не меняется вообще.
Выбор между воркерами и кластером следует из причины. Если медленный код исполняется в обработчиках запросов, воркеры. Если запросов просто много, а каждый быстрый, кластер добавляет циклов. Ошибку видно по мониторингу: лаг цикла при большом потоке мелких запросов лечится шириной, локальные десятки миллисекунд на отдельных операциях лечатся выносом.
Метрика лага цикла и мониторинг, который предупреждает раньше таймаутов
Здоровье цикла измеряется напрямую. Функция monitorEventLoopDelay из модуля perf_hooks, появившаяся в ветке 10.x, собирает гистограмму задержек: разницу между моментом, когда цикл собирался проснуться, и фактическим пробуждением. Из гистограммы берутся среднее и перцентили, и метрика event loop lag обязана стоять в мониторинге рядом с процессором и памятью:
const {monitorEventLoopDelay} = require("perf_hooks");
const h = monitorEventLoopDelay({resolution: 20});
h.enable();
setInterval(() => {
console.log("mean", h.mean().toFixed(1), "ms");
h.reset();
}, 10000);
Ориентиры простые. Лаг в единицы миллисекунд это норма, десятки на пике это повод смотреть очереди, сотни это задержки каждого запроса, которую уже видно клиентам. Рядом полезно держать графики открытых соединений и длину очереди к нитям пула, а перед плановыми пиками прогонять нагрузочное тестирование на стенде, а не в живом трафике.
Разрешение гистограммы задаётся при создании: грубое сглаживает краткие всплески, мелкое показывает их честно. Оповещения настраиваются по трём уровням: плавный рост лага это сигнал для разбора в рабочее время, ступенька вверх это вызов дежурного, вертикальный рост вместе с таймаутами это инцидент с выводом узла из ротации.
Правила поведения под пиковой нагрузкой и границы учебниковой модели
Продакшен-правила из всего вышесказанного собираются в короткий список:
- синхронные операции с файлами и тяжёлые разборы JSON не живут в обработчиках запросов, им готовятся воркеры или фоновая очередь с ограничением;
- массовый поиск имён идёт через сетевой метод dns.resolve, а файловые операции ограничиваются по параллелизму на уровне приложения;
- метрика event loop lag выводится на общий дашборд и оповещает раньше, чем клиенты начнут видеть таймауты;
- узел с растущим лагом выводится из ротации автоматически, чтобы балансировщик не отправлял ему новые соединения;
- таймеры используются как способ сказать "позже", а критичные интервалы сверяются с фактическим временем исполнения;
- масштабирование на уровне ядер делается кластером до того, как код перестанет справляться, а не после.
Учебниковая модель не отменяется, она остаётся правильным первым этажом понимания. Производственный этаж добавляет то, чего нет в картинке: фазы с очередями, приоритет микрозадач, пул нитей и лаг как число на графике. Разработчик, знающий только первый этаж, на ночных пиках вынужден угадывать. Разработчик со вторым этажом читает мониторинг как трассировку и чинит причину, а не её эхо. Нагрузка не меняет язык, она лишь честно показывает, какая часть модели была упрощением.