awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Что блокирует цикл: CPU-работа и синхронные вызовы

Суть У кооперативной конкурентности один фатальный режим отказа: любая синхронная работа на потоке цикла замораживает все соединения разом. Синхронные чтения файлов, JSON.parse на больших телах, синхронный crypto и катастрофические regex — виновники; лаг цикла выдаёт это первым.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 14 min

Эндпоинт health-check, который не делает ничего, кроме возврата 200 OK, начинает отваливаться по таймауту. Его никто не трогает; его код — две строки. Настоящая причина в трёх роутах отсюда: репортинговый эндпоинт вызывает JSON.parse на теле в 40 МБ, и те ~800 мс, что этот парс работает, единственный поток цикла занят, и каждый другой запрос — включая тривиальный health-check — сидит замороженным в очереди. Никто не писал медленный health-check. Кто-то написал одну медленную синхронную строку, и кооперативная конкурентность разнесла боль на весь процесс.

Один медленный колбэк стопорит всех

Выигрыш прошлого урока и его цена — один и тот же факт: колбэки выполняются до конца без вытеснения. Пока всё уступает быстро — await на I/O, быстрый возврат — тысячи соединений плавно чередуются. Но в момент, когда один колбэк делает синхронную работу, занимающую реальное время, цикл не может продвинуться к фазе poll, не может выполнить ни один другой I/O-колбэк, не может запустить ни один таймер. Цена не «этот запрос медленный». Это head-of-line blocking для всего процесса: каждый конкурентный запрос платит полную длительность, потому что все они ждут за одним колбэком, захватившим поток.

Это определяющий режим отказа модели. В сервере поток-на-соединение один медленный запрос замедляет тот поток; здесь один медленный синхронный отрезок замедляет все.

Обычные виновники

Блокирующая работа бывает двух видов: синхронные API, делающие I/O на потоке цикла, и CPU-bound вычисления, которые просто слишком долго идут между уступками.

  • Синхронные I/O APIfs.readFileSync, fs.writeFileSync, child_process.execSync. Они делают I/O на потоке цикла и могут застопорить его на сотни миллисекунд (синхронное чтение большого файла замерялось около 1200 мс). Асинхронные двойники (fs.promises.readFile) отдают работу и дают циклу продолжить.
  • Большой JSON.parse / JSON.stringify — парсинг или сериализация многомегабайтного тела — это чистый CPU на потоке цикла; крупный парс замерялся около 800 мс замороженного цикла.
  • Синхронный cryptobcrypt.hashSync при реалистичном cost factor блокирует примерно 200–400 мс на вызов; под нагрузкой логина одна эта строка обрушивает пропускную способность. Хеширование, crypto.pbkdf2Sync, большой gzipSync.
  • Катастрофический regex (ReDoS) — паттерн с вложенными квантификаторами вроде /A(B|C+)+D/ против специально подобранной строки может откатываться экспоненциально; один задокументированный случай потратил ~3,7 секунды чистого CPU на одном входе. Поскольку это на потоке цикла, атакующий может заморозить весь сервер одним запросом — отказ в обслуживании.

Лаг цикла: увидеть это раньше пользователей

Не нужно ждать жалоб на таймауты, чтобы найти блокировку. Прямой сигнал — лаг event loop (он же event-loop delay): запланируй таймер на t мс и замерь, насколько поздно он реально сработал. Если setTimeout(fn, 0) стабильно бежит на 200 мс позже, цикл был занят 200 мс — это опоздание и есть блокировка, в числах. Node даёт perf_hooks.monitorEventLoopDelay() для гистограммы (p50/p99 лага), а инструменты вроде clinic.js выводят это; обычный продакшен-порог алерта — около 100 мс лага.

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

Почему лаг цикла — лучший сигнал здоровья, чем загрузка CPU? CPU может показывать 100% по совершенно здоровой причине — цикл делает полезную, хорошо нарезанную работу и всё ещё уступает между порциями. Пользователям вредит не занятость CPU; вредит то, что цикл не возвращается к poll, чтобы обслужить ждущие сокеты. Лаг измеряет ровно этот разрыв: время между тем, когда колбэк должен был выполниться, и тем, когда цикл реально до него добрался. Сервер может сидеть на 60% CPU с 500 мс лага цикла (один жирный синхронный отрезок повторно) и быть куда больнее, чем на 95% CPU с 2 мс лага (ровная, уступающая работа). Поэтому старшие команды алертят на event-loop delay и event-loop utilization (ELU), а не только на CPU — лаг это метрика, коррелирующая с таймаутами, которые пользователи реально ощущают.

Мысленный тест

Прежде чем любая строка выполнится на потоке цикла, старший рефлекс — один вопрос: это ограничено и быстро или может бежать десятки миллисекунд на большом входе? Прочитать 2 КБ конфига синхронно на старте — нормально. Парсить произвольный пользовательский JSON неизвестного размера, хешировать пароль или сопоставлять пользовательскую строку с откатывающимся regex на пути запроса — нет; этому место вне цикла, что и есть следующий урок.

Виновник блокировкиПримерное время заморозкиПочему блокируетНаправление фикса
fs.readFileSync (большой)~1200 мсI/O на потоке циклаАсинхронный fs.promises
JSON.parse (много МБ)~800 мсЧистый CPU на циклеСтрим / worker thread
bcrypt.hashSync~200–400 мс/вызовCPU на циклеАсинхронный bcrypt (пул libuv)
Катастрофический regex (ReDoS)секунды, контроль атакующегоЭкспоненциальный откат на циклеБезопасный regex / таймаут / валидация
Викторина

Тривиальный health-check отваливается по таймауту всякий раз, когда репортинговый роут запускает `JSON.parse` на теле в 40 МБ. Почему страдает health-check?

Викторина

Почему лаг event loop часто лучший сигнал здоровья, чем загрузка CPU?

Викторина

Почему катастрофически откатывающийся regex на пути запроса — это риск отказа в обслуживании именно в рантайме на event loop?

Вспомните перед уходом
  1. 01
    Почему один медленный синхронный колбэк замораживает весь сервер, а не только свой запрос?
  2. 02
    Что обычно блокирует цикл и примерно насколько надолго замораживает его?
  3. 03
    Что такое лаг event loop, как его измерить и почему он лучше загрузки CPU как сигнал здоровья?
Итог

Сила кооперативной конкурентности — колбэки выполняются до конца без вытеснения — это и её единственный фатальный режим отказа: любой синхронный отрезок на потоке цикла замораживает все соединения разом, поэтому медленная строка в трёх роутах отсюда может выбить по таймауту двухстрочный health-check. Виновники делятся на синхронный I/O на цикле (fs.readFileSync, около 1200 мс), тяжёлый CPU между уступками (JSON.parse многомегабайтного тела около 800 мс, bcrypt.hashSync на 200–400 мс на вызов) и контролируемые атакующим катастрофические regex, которые откатываются секундами и превращают один запрос в отказ в обслуживании. Всё это видно раньше пользователей через лаг event loop — опоздание запланированного таймера, выводимое monitorEventLoopDelay и алертимое около 100 мс — что более правдивый сигнал здоровья, чем CPU, потому что занятый-и-уступающий нормален, а занятый-и-застопоренный нет. Рефлекс — спросить, ограничена ли любая строка на цикле и быстра или может бежать десятки миллисекунд на большом входе; медленным место полностью вне цикла, что и есть следующий урок: worker threads, пул libuv и нарезка CPU-работы.

Связанные уроки
встречается в185
Продолжить восхождение ↑Вынос CPU-работы: worker threads и пул libuv
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.