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

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

Вынос CPU-работы: worker threads и пул libuv

Суть За единственным циклом прячутся два пула потоков, и их путаница стоит недель. Пул libuv выполняет нативный I/O вроде fs и crypto, но никогда твой JavaScript; CPU-bound JS требует worker threads или нарезки, у каждого своя цена — оверхед копирования сообщений, сложность разбиения.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 15 min

Команда профилирует медленный эндпоинт ресайза картинок, находит CPU-тяжёлую работу и «чинит» её, подняв UV_THREADPOOL_SIZE с 4 до 32. Ничего не улучшается. Ресайз — это JavaScript, бегущий на потоке цикла, а пул libuv, который они только что увеличили, вообще не выполняет JavaScript — он выполняет нативный I/O. Они тюнили не тот пул. Node прячет два ресурса исполнения за единственным event loop, и знание, какая работа куда идёт, — это разница между фиксом и потерянной неделей.

Два пула, две задачи

За единственным циклом — два разных источника параллелизма, и они служат противоположным видам работы:

  • Пул потоков libuv — небольшой пул (по умолчанию 4 потока, настраивается до 1024 через UV_THREADPOOL_SIZE), выполняющий нативные операции без асинхронного примитива ОС: вызовы файловой системы, DNS-резолвинг (dns.lookup) и асинхронные функции crypto/zlib. Он не выполняет твой JavaScript. Когда ты вызываешь fs.promises.readFile, libuv делает блокирующее чтение на потоке пула и постит результат обратно в цикл. Поэтому асинхронный bcrypt не замораживает цикл — хеширование бежит на потоке libuv.
  • Worker threads (worker_threads) — реальные, отдельные изоляты V8 со своим event loop, дающие истинный многоядерный параллелизм для твоего CPU-bound JavaScript. Сюда относятся ресайз картинок, парсинг больших полезных нагрузок, сжатие и любые тяжёлые вычисления.

Баг с ресайзом теперь очевиден: ресайз — это JS, поэтому ему нужен worker thread, а не больший пул libuv. Увеличение libuv помогает, только когда упёрся в пропускную способность fs/dns/crypto — и даже тогда больше потоков пула, чем ядер CPU, в основном добавляет конкуренцию.

Цена воркера: перемещение данных

Worker threads не бесплатны, и счёт в основном за передачу данных. По умолчанию всё, что ты postMessage воркеру, глубоко копируется алгоритмом structured-clone — для большого буфера эта копия — реальная работа (копия многомегабайтного ArrayBuffer замерялась около 268 мс против примерно 29 мс при передаче). Два обходных пути важны:

  • Transferables — передай ArrayBuffer в transferList, и владение перемещается воркеру без копии (отправитель больше не может им пользоваться). Почти нулевая цена передачи.
  • SharedArrayBuffer — разделяемая память, которую видят оба потока сразу, с Atomics для безопасной координации. Без копии, без передачи владения; правильный инструмент, когда обеим сторонам нужны одни байты.

Поэтому старший расчёт для выноса такой: работа должна быть достаточно CPU-тяжёлой, чтобы затмить цену передачи сообщений. Вынос вычисления на 2 мс воркеру может оказаться медленнее, чем просто выполнить его, как только заплатишь за клон и круговой рейс.

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

Почему не поднимать свежий воркер на каждый запрос? Создание потока и старт изолята дороги (десятки миллисекунд и реальная память на воркер), поэтому воркеры-на-запрос превращают CPU-проблему в проблему текучки потоков. Продакшен-паттерн — пул воркеров: создать фиксированный набор воркеров один раз (обычно ~один на ядро CPU), раздавать им задачи через очередь и переиспользовать. Это ограничивает параллелизм железом, которое реально есть — восемь ядер не могут по-настоящему выполнить девять CPU-bound задач разом — и избегает оплаты старта на каждом вызове. Это зеркалит пулинг соединений: ресурс дорог в создании, дёшев в переиспользовании и опасен в неограниченном создании. Библиотеки вроде Piscina существуют именно чтобы этим управлять, чтобы ты не писал очередь и жизненный цикл вручную.

Когда не выносить: нарезай вместо этого

Не каждое долгое вычисление требует потока. Если работу можно разбить на мелкие куски, ты можешь выполнить кусок, затем setImmediate (или await разрешённого промиса), чтобы уступить цикл, затем выполнить следующий кусок — давая I/O-колбэкам вклиниваться между кусками. Это держит всё на потоке цикла без цены передачи, меняя общую пропускную способность (работа теперь конкурирует с запросами) на отзывчивый цикл. Нарезка подходит работе, которая длинная, но прерываемая (итерация большого массива, постраничное вычисление). Worker threads подходят работе монолитной и тяжёлой (один ресайз, операция crypto) или той, что ты искренне хочешь гонять параллельно на другом ядре.

И иногда правильный ответ — ни то ни другое: если CPU — узкое место по всему сервису, горизонтальное масштабирование — больше процессов (cluster) или больше машин — это рычаг, потому что один процесс Node маппится на один цикл, а CPU-bound пропускная способность фундаментально проблема числа ядер.

ПодходЧто выполняетИстинный параллелизмГлавная цена
Пул libuv (4, настраивается)Нативный fs/dns/crypto/zlibДа, для нативного I/OНе тот инструмент для JS CPU-работы
Worker threadТвой CPU-bound JSДа, другое ядроКопия / передача данных + старт
Нарезка + setImmediateТвой JS, кускамиНет (один цикл)Ниже пропускная, ручное разбиение
Cluster / больше машинРеплика всего процессаДа, больше цикловСложность ops, координация состояния
Викторина

CPU-тяжёлый ресайз картинки (чистый JavaScript) блокирует цикл. Почему повышение `UV_THREADPOOL_SIZE` не помогает?

Викторина

Ты выносишь большой буфер воркеру и обнаруживаешь, что круговой рейс доминирует копированием. Какой самый прямой фикс?

Викторина

Когда нарезка с `setImmediate` — лучший выбор, чем worker thread?

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

Два ресурса исполнения прячутся за единственным event loop, и хороший вынос значит отправить каждый вид работы к правильному. Пул потоков libuv — четыре потока по умолчанию, настраивается до 1024 — выполняет нативный I/O вроде fs, dns, crypto и zlib и никогда твой JavaScript, поэтому его увеличение ничего не даёт JS CPU-узкому месту (ловушка ресайза картинок). CPU-bound JavaScript принадлежит worker threads, отдельным изолятам V8, дающим истинный многоядерный параллелизм, но они выставляют счёт за перемещение данных: structured-clone глубоко копирует по умолчанию (около 268 мс для большого буфера против 29 мс при передаче), поэтому тянись к transferables или SharedArrayBuffer и переиспользуй фиксированный пул воркеров, а не порождай по одному на запрос, чтобы избежать текучки старта. Когда работа длинная, но разбиваемая, нарезай её и уступай через setImmediate, чтобы держать цикл отзывчивым ценой пропускной способности; когда CPU — потолок всего сервиса, масштабируйся горизонтально, потому что один процесс — это один цикл. С тяжёлой работой, убранной с критического пути, следующий урок берётся за управление I/O-работой, что остаётся: backpressure и ограниченная конкурентность, чтобы система соответствовала собственной скорости потребления, а не тонула в неограниченном fan-out.

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

Trademarks belong to their respective owners. Editorial reference only.