Что блокирует цикл и как не блокировать
Урок показывает, какие операции замораживают событийный цикл 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) — рост задержки сигналит о блокировке раньше пользователей.