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

Мониторинг задержки event loop

Node гонит JS в одном потоке, поэтому один медленный синхронный вызов тормозит все таймеры и I/O-колбэки в очереди. Мерь задержку event loop через monitorEventLoopDelay, следи за p99, а не средним, и выноси CPU с потока.

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Эндпоинт экспорта добавил «маленькую» фичу: сжимать ответ синхронным zlib.gzipSync перед отправкой. В нагрузочных тестах по этому одному маршруту всё было нормально — 40мс, зелёный. В проде p99 латентность на каждом эндпоинте — health-чеки, логины, не связанные чтения — прыгала с 8мс до 600мс, как только бежал экспорт. Никто не трогал те маршруты. Дашборд маршрута экспорта выглядел здоровым, потому что медленный путь был CPU-всплеском, который бежал между запросами, замораживая единственный поток на ~250мс за раз. Команда пялилась в графики латентности по маршрутам, а реальный сигнал был одним числом, которое они не писали: задержкой event loop.

Почему одна медленная задача замораживает всё

Node гонит твой JavaScript в одном потоке. Event loop — это цикл, который берёт следующий готовый колбэк (истёкший таймер, завершённый I/O, зарезолвленный промис), выполняет его до конца, и только потом ищет следующий. Вытеснения нет: пока твой колбэк выполняется, ничто другое в процессе бежать не может. Поэтому один синхронный CPU-всплеск или блокирующий вызов — zlib.gzipSync, crypto.pbkdf2Sync, JSON.parse на 5МБ, fs.readFileSync по холодному диску — не просто замедляет эту одну операцию. Он держит поток, и каждый таймер, каждое чтение сокета, каждый колбэк HTTP в очереди ждут за ним. Это время ожидания — задержка event loop (event-loop lag): разрыв между тем, когда колбэк должен был выполниться, и когда loop до него реально добрался.

Под конкурентностью этот сбой жесток. С одним запросом в полёте блок на 250мс стоит этому одному запросу 250мс. С 200 запросами в полёте блок падает в середину очереди и добавляет до 250мс латентности целому куску этих 200 — ни один из которых не сделал ничего плохого. Вот почему дашборды по маршрутам из хука врали: собственная латентность маршрута экспорта выглядела нормальной, но он травил tail latency (задержки на хвосте распределения — p99 и выше) всего остального. Медленный код и медленный симптом были на разных графиках.

import http from "node:http";
import zlib from "node:zlib";

http.createServer((req, res) => {
  // ❌ блокирует единственный поток на всё сжатие — колбэк каждого
  // другого запроса в полёте ждёт за этим.
  const body = zlib.gzipSync(bigPayload);   // ~250мс чистого CPU
  res.setHeader("content-encoding", "gzip");
  res.end(body);
}).listen(3000);

Фазы loop (и где копится задержка)

Loop бежит фиксированными фазами, каждая сливает свою очередь колбэков перед переходом дальше: timers (колбэки setTimeout/setInterval, чьё время вышло) → pending callbacks (часть отложенных системных колбэков) → poll (где забираются завершения I/O и где loop блокируется в ожидании I/O, если больше ничего не готово) → check (колбэки setImmediate) → close (например, socket.on('close')). Между каждым колбэком Node сливает очереди микротасков: сначала process.nextTick, потом зарезолвленные промисы.

Практическое следствие: задержка копится везде, где колбэк засиделся. Тяжёлое тело setTimeout задержит следующий таймер; тяжёлый .then промиса задержит следующий I/O, который фаза poll хотела доставить. А process.nextTick — острый край: поскольку он сливается перед промисами и до того, как loop сменит фазу, рекурсивный nextTick может полностью заморить loop, навсегда блокируя I/O, делая «крошечную» работу на каждом тике.

// process.nextTick сливается до того, как loop продолжит — рекурсия здесь
// полностью морит I/O, хотя каждый вызов выглядит тривиально.
function spin() { process.nextTick(spin); }
spin();              // ❌ фаза poll больше никогда не выполнится

Измерение задержки: monitorEventLoopDelay

Правильный инструмент в ядре: perf_hooks.monitorEventLoopDelay. Он семплит loop по таймеру с фиксированным resolution (по умолчанию 10мс) и пишет в высокоточную гистограмму, насколько поздно был каждый семпл. Ты читаешь с неё перцентили — и перцентили это весь смысл, потому что задержка взрывная: среднее в 2мс может прятать p99 в 400мс во время компакции или GC-паузы. Следи за хвостом (p99/max), не за средним. Все значения в наносекундах.

import { monitorEventLoopDelay } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 20 }); // семплить каждые 20мс
h.enable();

setInterval(() => {
  console.log({
    meanMs: (h.mean / 1e6).toFixed(2),       // здоровое: меньше миллисекунды
    p99Ms:  (h.percentile(99) / 1e6).toFixed(2),
    maxMs:  (h.max / 1e6).toFixed(2),
  });
  h.reset();                                  // начать свежее окно
}, 1000).unref();

Наивная альтернатива — дрейф таймера: запланируй setInterval(fn, 100) и измерь, насколько позже 100мс реально срабатывает каждый тик; перебор и есть задержка. Это дёшево и без зависимостей, но грубо — он семплит только с твоим интервалом и не даёт гистограммы, так что упускает всплески внутри интервала и не скажет тебе p99. Для прода monitorEventLoopDelay строго лучше. Дальше библиотеки вроде toobusy-js непрерывно опрашивают задержку и дают сбрасывать нагрузку (shed load): когда задержка пересекает порог, сразу отклоняй новые запросы с 503 вместо того, чтобы брать работу, которую не сможешь обслужить, держа уже принятые запросы быстрыми.

СимптомМетрика, что его вскрываетИнструмент
Не связанные маршруты прыгают вместеp99 / max задержки loop (не среднее)гистограмма monitorEventLoopDelay()
Одна операция медленна на горячем путидлительность размеченного спанаperformance.mark/measure + PerformanceObserver
Сервис перегружен, хвост взрываетсяживая задержка vs порогtoobusy-js → сбрасывай нагрузку 503

Хронометраж подозреваемого: performance.mark, measure, observe

Когда задержка высока, всё равно надо найти, какой вызов блокирует. perf_hooks.performance даёт высокоточные часы и структурный способ мерить спаны без арифметики Date.now(). Используй performance.mark(name), чтобы ставить отметки времени, performance.measure(name, start, end), чтобы записать длительность между двумя отметками, и PerformanceObserver, чтобы получать эти записи по мере их выпуска — так хронометраж развязан с горячим путём.

import { performance, PerformanceObserver } from "node:perf_hooks";

const obs = new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.duration > 50) console.warn(`SLOW ${e.name}: ${e.duration.toFixed(1)}ms`);
  }
});
obs.observe({ entryTypes: ["measure"] });

performance.mark("gzip:start");
const body = zlib.gzipSync(bigPayload);
performance.mark("gzip:end");
performance.measure("gzip", "gzip:start", "gzip:end"); // появится в observer

Для разовых проверок performance.now() — это монотонный, субмиллисекундный таймер (не зависит от смены настенного времени): const t = performance.now(); …; performance.now() - t — правильный способ микро-замерить подозрительный вызов.

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

Почему наносекунды и гистограмма, а не одно среднее? Потому что задержка event loop — учебниковый случай метрики, где среднее врёт. Большинство семплов около нуля, так что среднее остаётся крошечным, даже пока горстка блоков по 300мс в минуту крушит реальных пользователей. Гистограмма хранит распределение, так что percentile(99) отвечает на важный вопрос — «насколько плохо невезучему 1% колбэков?» — а это ровно та популяция, из которой сделан твой tail latency.

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

Эндпоинт должен хешировать пароли и, отдельно, рендерить большой PDF — оба CPU-тяжёлые. Задержка loop скачет, не связанные маршруты тормозят. Какой сеньорский фикс?

Викторина

Среднее задержки loop — 1.5мс, но пользователи жалуются на случайные медленные запросы. Почему среднее тут вводит в заблуждение и на что смотреть?

Викторина

Какая строка, поставленная на горячий путь запроса, наиболее прямо раздует задержку event loop для ВСЕХ конкурентных запросов?

Расставь шаги по порядку

Расставь шаги диагностики высокой tail latency, которую подозреваешь как задержку event loop, — от первого сигнала до устойчивого фикса:

  1. 1 Замечаешь, что много не связанных маршрутов прыгают по p99 вместе при здоровых средних
  2. 2 Включаешь monitorEventLoopDelay и подтверждаешь, что высок p99/max задержки, а не среднее
  3. 3 Ставишь performance.mark/measure вокруг подозрительных вызовов, чтобы найти, какой блокирует
  4. 4 Выносишь блокирующую работу с потока (async API / worker_threads) или режешь её на куски
  5. 5 Перемеряешь: подтверждаешь, что p99 задержки вернулся к субмиллисекунде, и добавляешь сброс нагрузки как страховку
Вспомните перед уходом
  1. 01
    Почему среднее задержки event loop прячет проблему и что писать вместо него?
  2. 02
    Эндпоинт делает синхронную CPU-тяжёлую задачу (sync gzip / pbkdf2) и проваливает throughput для всех клиентов. В чём фикс и почему он не блокирует?
Итог

Node гонит твой JavaScript в одном потоке без вытеснения, так что event loop выполняет каждый готовый колбэк до конца перед тем, как тронуть следующий — а значит один синхронный CPU-всплеск или блокирующий вызов (zlib.gzipSync, pbkdf2Sync, JSON.parse на много МБ, sync fs) замораживает весь процесс и заставляет каждый таймер и I/O-колбэк в очереди ждать за ним. Это ожидание — задержка event loop, и под конкурентностью она ложится на p99 каждого запроса в полёте, а не только медленного, поэтому графики латентности по маршрутам могут выглядеть здоровыми, пока сервис горит. Loop сливает фиксированные фазы — timers → pending → poll (где блокируется ради I/O) → check (setImmediate) → close — и микротаски (process.nextTick, потом промисы) между каждым колбэком, так что рекурсивный nextTick может полностью заморить I/O. Мерь задержку через perf_hooks.monitorEventLoopDelay({ resolution }), который пишет наносекундную гистограмму, читаемую через .mean, .max и .percentile(99) и .reset() на окно — и всегда следи за хвостом, потому что среднее взрывного сигнала это ложь. Локализуй виновный вызов через performance.mark/measure и PerformanceObserver (или performance.now() для разового замера), затем чини, вынося CPU-работу с loop через async core API или worker_threads, либо нарезая её через setImmediate. Субмиллисекундный p99 задержки — здоровье; десятки-сотни мс означают, что пользователи это чувствуют — так что алертись по p99, а не по среднему, и добавь сброс нагрузки, чтобы перегрузка деградировала в честные 503, а не в замороженный поток. Теперь, когда несвязанные маршруты начинают прыгать вместе, твой первый вопрос: каков p99 задержки event loop — и какой синхронный вызов держит поток?

Практика

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

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки

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

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

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

Trademarks belong to their respective owners. Editorial reference only.