Что блокирует цикл и как не блокировать

Урок показывает, какие операции замораживают событийный цикл Node и какими приёмами снять с него нагрузку.

Блокировка цикла — ситуация, когда синхронный код держит главный поток, и Node не обслуживает ни таймеры, ни I/O, ни новые соединения, пока этот код не вернёт управление.

Однопоточность Node — палка о двух концах. Пока вы делаете асинхронный I/O, один поток тянет тысячи соединений. Но стоит запустить тяжёлую синхронную операцию — и весь сервер замирает: запросы копятся, ответы не уходят, таймеры опаздывают. Нет второго потока, который бы подхватил работу. Поэтому в Node действует золотое правило: не блокируй событийный цикл.

В этом уроке разберём, что именно блокирует цикл (часто неочевидные вещи вроде JSON.parse на огромной строке или синхронного crypto), как вынести CPU-нагрузку и как измерить, насколько сильно цикл «лагает».

Зачем это на практике

Типичный продакшен-инцидент: сервис под нагрузкой начинает отвечать с задержками в секунды, хотя CPU не загружен на 100%. Причина почти всегда одна — где-то в горячем пути затесалась синхронная операция, которая держит поток. Найти и убрать такие места — прямой путь к стабильным latency. А мониторинг лага цикла даёт раннее предупреждение, ещё до того как пользователи заметят тормоза.

Что блокирует цикл

Блокирует любой синхронный код, выполнение которого занимает заметное время. Главные виновники:

  • Тяжёлые вычисления. Длинные циклы, перебор больших массивов, рекурсия — всё это держит поток на всё время счёта.
  • Синхронные методы fs. fs.readFileSync, fs.writeFileSync блокируют поток на время дисковой операции. В стартовом скрипте это нормально, в обработчике запроса — катастрофа.
  • Синхронный crypto. crypto.pbkdf2Sync, crypto.scryptSync намеренно медленные (так задумано для хеширования паролей) и блокируют поток на десятки-сотни миллисекунд.
  • Большой JSON.parse / JSON.stringify. На строке в десятки мегабайт это синхронная операция на сотни миллисекунд.
  • Регулярки с катастрофическим бэктрекингом (ReDoS) — на «плохом» входе одна регулярка может крутиться секундами.

Покажем эффект блокировки чистым JS (его можно запустить — Node-API он не трогает). Тяжёлый синхронный цикл задерживает то, что должно было идти после него.

console.log('старт');

const t0 = Date.now();
let sum = 0;
for (let i = 0; i < 50_000_000; i++) {
  sum += i;
}
console.log('сумма посчитана за блокирующий цикл, мс:', Date.now() - t0 >= 0 ? 'готово' : 'ошибка');
console.log('результат:', sum);
console.log('всё это время поток был занят и ничего другого делать не мог');

Вывод:

старт
сумма посчитана за блокирующий цикл, мс: готово
результат: 1249999975000000
всё это время поток был занят и ничего другого делать не мог

Пока крутится for, поток занят. В реальном сервере в эти миллисекунды не обработался бы ни один запрос. На 50 миллионах итераций задержка невелика, но умножьте масштаб — и сервис встанет.

Как не блокировать: выносим CPU-нагрузку

Стратегий несколько, выбор зависит от задачи.

1. Асинхронные версии вместо синхронных

Самое простое: меняйте *Sync-методы на их колбэк- или промис-версии. Они уводят работу в пул потоков libuv, не блокируя главный поток.

// Плохо: блокирует главный поток на время чтения
const data = fs.readFileSync('big.log');

// Хорошо: дисковая операция уходит в пул потоков libuv
const data = await fs.promises.readFile('big.log');

// crypto тоже имеет async-вариант — он уйдёт в пул потоков
crypto.pbkdf2(pass, salt, 100000, 64, 'sha512', (err, key) => { /* ... */ });

2. Разбить работу на куски через setImmediate

Если вычисление нельзя убрать, но можно нарезать, обрабатывайте данные порциями, отдавая управление циклу между ними через setImmediate. Так между кусками Node успевает обслужить I/O.

function processInChunks(items, i = 0) {
  const end = Math.min(i + 1000, items.length);
  for (; i < end; i++) heavyWork(items[i]);
  if (i < items.length) {
    setImmediate(() => processInChunks(items, i)); // отдаём управление циклу
  }
}

3. Worker threads для настоящего параллелизма

Для серьёзной CPU-нагрузки выносите её в worker threads — отдельные потоки V8 с собственным событийным циклом. Главный поток отправляет задание, воркер считает в своём потоке, результат прилетает сообщением. Главный цикл при этом свободен.

const { Worker } = require('worker_threads');

function runHeavyTask(payload) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./heavy-worker.js', { workerData: payload });
    worker.on('message', resolve);
    worker.on('error', reject);
  });
}

Эти три блока используют fs/crypto/worker_threads — Node-API, поэтому помечены language-text и в браузере не исполняются.

Как это работает под капотом

Асинхронные fs и часть crypto работают через пул потоков libuv (4 потока по умолчанию). Когда вы зовёте fs.promises.readFile, libuv поручает чтение потоку из пула; главный JS-поток свободен и продолжает цикл. По завершении результат кладётся в очередь фазы poll. Важный предел: потоков всего 4 — запустите 100 одновременных тяжёлых хешей crypto, и они выстроятся по 4 в очередь (размер пула меняется переменной окружения UV_THREADPOOL_SIZE).

Worker threads — другой механизм: это полноценные потоки с собственным экземпляром V8 и своим событийным циклом, общающиеся через передачу сообщений (и при желании разделяемую память SharedArrayBuffer). Они дают истинный параллелизм для CPU-задач, тогда как пул libuv — лишь для конкретных встроенных операций ввода-вывода и крипто.

Мониторинг лага цикла

«Лаг» событийного цикла — насколько таймер опоздал сработать по сравнению с запланированным временем. Если запланировать колбэк через 0 мс, а он выполнится через 80 — значит, цикл был занят и «отстал» на 80 мс. Идея замера: ставим интервал на фиксированную задержку и смотрим фактическое отклонение.

let last = Date.now();
setInterval(() => {
  const now = Date.now();
  const lag = now - last - 500;   // ожидали 500 мс, считаем превышение
  last = now;
  if (lag > 50) console.warn('event loop лагает, мс:', lag);
}, 500);

В реальных проектах для этого есть встроенный perf_hooks.monitorEventLoopDelay() — он даёт точную гистограмму задержек с минимальными накладными расходами. Метрику лага полезно слать в систему мониторинга: рост лага — ранний сигнал, что в горячий путь просочилась блокирующая операция.

Частые ошибки

Синхронные *Sync-методы в обработчиках запросов. readFileSync в стартовом скрипте безобиден, но в коде, обслуживающем трафик, он блокирует все соединения на время операции. Под нагрузкой это убивает latency.

Синхронное хеширование паролей. scryptSync/pbkdf2Sync в обработчике логина блокируют поток на каждый вход. Используйте async-варианты — они уходят в пул потоков.

Считать worker threads бесплатными. Создание воркера и передача данных стоят времени и памяти. Для мелких задач накладные расходы съедят выигрыш — воркеры оправданы для действительно тяжёлых вычислений.

Игнорировать предел пула в 4 потока. Лавина одновременных async-fs/crypto упрётся в 4 потока и выстроится в очередь. При интенсивном I/O поднимайте UV_THREADPOOL_SIZE.

Не мониторить лаг цикла. Без метрики блокировки обнаруживаются только по жалобам пользователей. monitorEventLoopDelay ловит их заранее.

Итоги

  • Блокирует любой долгий синхронный код: тяжёлые циклы, *Sync-методы fs, синхронный crypto, большой JSON.parse, ReDoS-регулярки.
  • Первый приём — заменить синхронные методы на асинхронные: они уходят в пул потоков libuv.
  • Неустранимую CPU-нагрузку режьте на куски через setImmediate или выносите в worker_threads.
  • Пул потоков libuv — 4 потока (меняется через UV_THREADPOOL_SIZE); worker threads дают истинный параллелизм.
  • Мониторьте лаг цикла (perf_hooks.monitorEventLoopDelay) — рост задержки сигналит о блокировке раньше пользователей.
Проверьте себя
1. Какая из операций НЕ блокирует событийный цикл?
Afs.readFileSync на большом файле
Bcrypto.pbkdf2Sync с большим числом итераций
Cawait fs.promises.readFile
DJSON.parse на строке в десятки мегабайт
2. Чем worker threads отличаются от пула потоков libuv?
AЭто одно и то же, просто разные названия
BWorker threads — полноценные потоки с собственным V8 и циклом для истинного параллелизма CPU-задач, а пул libuv обслуживает только встроенные I/O и crypto
CWorker threads работают медленнее и не дают параллелизма
DПул libuv предназначен для пользовательских вычислений, а worker threads — только для сети
3. Что показывает «лаг событийного цикла»?
AСколько памяти потребляет процесс
BНасколько таймер опоздал сработать относительно запланированного времени — то есть как сильно цикл был занят
CКоличество открытых сетевых соединений
DРазмер пула потоков libuv