Когда параллелизм нужен (и когда нет)

Параллелизм в Node — не для скорости вообще, а для конкретного дефицита: либо не хватает одного ядра под вычисление, либо одного процесса под нагрузку. В остальных случаях он только вредит.

I/O-bound задача большую часть времени ждёт (диск, сеть, БД); CPU-bound задача большую часть времени считает. Событийный цикл Node идеален для первых и беспомощен для вторых — на этом и строится выбор инструмента.

Мы разобрали три механизма: worker_threads, cluster, child_process. Соблазн — хвататься за них при любом «надо быстрее». Но параллелизм не бесплатен: он добавляет процессы или потоки, память, накладные расходы на обмен данными и сложность кода. Этот заключительный урок — про трезвый выбор: научиться отличать задачу, которой параллелизм действительно нужен, от задачи, где он лишь всё усложнит и замедлит.

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

Самая частая ошибка новичка в этой теме — «обернуть в worker» обычный запрос к базе данных, надеясь ускорить его. Результат обратный: запрос и так не блокировал поток (он ждал в фоне), а теперь к нему добавились старт потока, копирование данных через границу и сложность отладки. И наоборот: сервер, который синхронно хеширует пароли в основном потоке, тормозит на ровном месте, а разработчик добавляет ядра и реплики, не понимая, что один тяжёлый bcrypt блокирует весь процесс. Правильный диагноз «I/O или CPU» экономит и деньги на инфраструктуру, и недели на оптимизацию не того места.

Почему для I/O хватает событийного цикла

Ключевая идея Node: операции ввода-вывода не занимают поток, пока выполняются. Когда вы читаете файл или шлёте запрос в БД, Node передаёт операцию операционной системе (через libuv) и сразу освобождает поток для других дел. Когда данные готовы, в очередь событийного цикла попадает колбэк. Поэтому один поток Node спокойно держит тысячи одновременных соединений: в каждый момент он реально работает лишь над тем, что уже готово, а всё ожидающее «висит» в ОС, ничего не потребляя.

// 1000 запросов к БД запускаются почти одновременно в ОДНОМ потоке
const results = await Promise.all(
  ids.map((id) => db.query('SELECT * FROM users WHERE id = $1', [id]))
);
// поток не блокирован: пока БД отвечает, событийный цикл свободен.
// никакие worker_threads тут не нужны и не ускорят — узкое место не в CPU Node,
// а в самой БД и сети

Вот почему «параллелить» I/O в Node нечего: конкурентность ввода-вывода уже встроена в саму модель. Узкое место I/O-bound нагрузки — это пропускная способность диска, сети или БД, а не процессор Node.

Почему CPU ломает эту модель

Чистое вычисление — другая история. Оно не уходит в ОС и не «ждёт»: оно занимает поток на всё время счёта. А поток у событийного цикла один. Пока он считает, очередь колбэков стоит — все остальные запросы ждут.

// каждый такой запрос замораживает ВЕСЬ сервер на время вычисления
app.get('/report', (req, res) => {
  const data = crunchHugeDataset(); // 3 секунды чистого CPU
  res.json(data);                   // всё это время /health, /login и т.д. не отвечают
});

Вот единственный случай, где Node действительно нужен параллелизм: чтобы тяжёлый счёт не останавливал обслуживание всего остального. Здесь и выходят на сцену worker_threads (вынести вычисление в поток) или cluster (чтобы хотя бы другие процессы продолжали отвечать, пока один занят).

Цена межпроцессного и межпоточного обмена

Параллелизм не бесплатен, и за «бесплатное ускорение» легко переплатить. Главные статьи расходов:

РасходВ чём проявляется
старт исполнителяновый поток (worker) или процесс (fork/cluster) поднимается десятки мс и ест память
сериализация данныхобъекты копируются через границу (structured clone / IPC) — большие данные дороги
переключение контекстаесли потоков/процессов больше, чем ядер, ОС тратит время на их чередование
сложность кодагонки, координация, отладка нескольких исполнителей вместо одного

Практический вывод из этой таблицы: если вычисление длится миллисекунды, накладные расходы на передачу его в worker и обратно могут превысить саму работу — выгоднее посчитать на месте. Параллелизм окупается, когда полезная работа заметно дороже стоимости её передачи и старта исполнителя. Для часто повторяющихся CPU-задач старт амортизируют пулом заранее поднятых воркеров, а данные стараются передавать без копирования (transfer буферов) или не передавать вовсе.

Как выбрать инструмент

Свести весь раздел можно в короткое дерево решений:

СитуацияИнструмент
Ожидание диска/сети/БД (I/O-bound)ничего — обычный async/await, событийный цикл справится
Тяжёлое вычисление внутри запроса (CPU-bound)worker_threads (+ пул), чтобы не блокировать цикл
Много параллельных запросов, упор в одно ядроcluster — занять все ядра процессами-копиями
Нужна готовая внешняя программа (ffmpeg, git)child_process (spawn/execFile)
Высокая нагрузка + тяжёлые вычисления вместеcluster + worker_threads вместе

Эти инструменты не конкурируют, а дополняют друг друга. Типичная зрелая конфигурация: cluster поднимает по воркеру на ядро для пропускной способности, а внутри каждого воркера пул worker_threads уносит редкие тяжёлые вычисления, чтобы они не подвешивали обслуживание лёгких запросов.

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

Под всей событийной моделью лежит библиотека libuv — именно она реализует событийный цикл и пул потоков для тех немногих операций, которые ОС не умеет делать асинхронно (часть файловых операций, DNS, криптография). Поэтому утверждение «Node однопоточен» точнее звучит как «ваш JavaScript исполняется в одном потоке»: под капотом libuv уже использует несколько потоков для I/O, и размер этого пула регулируется переменной UV_THREADPOOL_SIZE. Сетевой ввод-вывод вообще обходится без этого пула — он опирается на механизмы ОС (epoll, kqueue, IOCP), которые уведомляют о готовности сокетов без выделенных потоков. Вот почему сеть масштабируется в Node так дёшево, а упор в производительность почти всегда означает CPU в вашем коде, а не «нехватку потоков» для ввода-вывода.

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

  • «Параллелить» I/O. Запрос к БД в worker — это потеря: ввод-вывод и так конкурентен, узкое место не в CPU Node. Добавятся лишь накладные расходы.
  • Не замечать CPU-блокировку. Синхронный bcrypt, JSON.parse гигантского тела, обработка изображения в основном потоке морозят весь сервер — и это лечится не репликами, а worker'ом.
  • Создавать исполнителя на каждую мелкую задачу. Если работа короче стоимости старта потока/процесса и копирования данных, параллелизм замедляет. Считайте на месте или используйте пул.
  • Путать конкурентность и параллелизм. Событийный цикл даёт конкурентность (много задач в работе попеременно) на одном ядре; параллелизм (одновременное исполнение на разных ядрах) дают только потоки и процессы.
  • Масштабировать вслепую. Прежде чем добавлять ядра и реплики, проверьте профилировщиком, во что упёрлись — в CPU своего кода или в ожидание внешнего ресурса. Лекарства у них противоположные.

Итоги

  • I/O-bound задачи ждут (диск/сеть/БД) — для них достаточно событийного цикла и async/await, параллелизм не нужен.
  • CPU-bound задачи считают и занимают единственный поток целиком — только им и нужны worker_threads или cluster.
  • Конкурентность ввода-вывода встроена в Node через libuv; узкое место I/O — это внешний ресурс, а не процессор Node.
  • Параллелизм стоит денег (старт исполнителя, копирование данных, контекст, сложность) и окупается, лишь когда полезная работа дороже этих расходов.
  • Выбор: I/O → ничего, CPU в запросе → worker_threads, упор в ядро под нагрузкой → cluster, внешняя программа → child_process; для тяжёлого продакшена их комбинируют.
Проверьте себя
1. Почему I/O-bound задаче (например, запросу к базе данных) в Node не нужен параллелизм через worker_threads?
AПотому что базы данных не поддерживают многопоточность
BПотому что операции ввода-вывода не занимают поток во время ожидания — они уходят в ОС, а событийный цикл остаётся свободным
CПотому что worker_threads вообще не умеют работать с сетью
DПотому что запрос к БД всегда выполняется мгновенно
2. Чем CPU-bound задача отличается от I/O-bound с точки зрения событийного цикла?
ACPU-bound задача тоже уходит в ОС и не занимает поток
BCPU-bound задача занимает единственный поток на всё время вычисления, поэтому блокирует обработку всех остальных запросов
CCPU-bound задача всегда быстрее I/O-bound
DМежду ними нет разницы для событийного цикла
3. Когда вынос короткого вычисления в отдельный worker может оказаться вреднее, чем расчёт на месте?
AКогда вычисление длится несколько минут
BКогда полезная работа короче, чем суммарная стоимость старта потока и копирования данных через границу
Cworker всегда быстрее расчёта на месте
DКогда задача связана с сетью
4. Что из перечисленного точнее всего описывает фразу «Node.js однопоточен»?
ANode вообще не способен использовать больше одного ядра ни при каких условиях
BВ одном потоке исполняется ваш JavaScript, но под капотом libuv использует пул потоков для части операций, а сеть опирается на механизмы ОС
CNode создаёт ровно один процесс на всю операционную систему
DОднопоточность означает, что Node не умеет обрабатывать несколько запросов