Когда параллелизм нужен (и когда нет)
Параллелизм в 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; для тяжёлого продакшена их комбинируют.