Node.js выполняет ваш JavaScript в одном потоке, управляемом event loop. Эта модель отлично подходит для I/O-bound работы — чтения файлов, запросов к базе данных, обработки HTTP-запросов, — потому что Node передаёт ожидание операционной системе и держит поток свободным.
Однако для двух других ситуаций она подходит плохо:
- CPU-bound работа (изменение размера изображений, хеширование, парсинг огромных полезных нагрузок) занимает единственный поток и делает всё ваше приложение неотзывчивым, пока выполняется.
- Масштабирование по ядрам CPU — один процесс Node.js всегда использует для своего JavaScript только одно ядро, сколько бы ядер ни было у машины.
Node.js поставляется с тремя встроенными модулями для решения этого: node:child_process, node:worker_threads и node:cluster. Они решают пересекающиеся проблемы разными способами, поэтому легко потянуться не за тем. Это руководство сравнивает их бок о бок и разбирает, когда какой использовать.
| Выполняется | Память | Общается через | Лучше всего для | |
|---|---|---|---|---|
child_process | Отдельный процесс | Изолирована | stdio или IPC (fork()) | Запуска внешних программ |
worker_threads | Отдельный поток | Разделяема | postMessage() | CPU-bound JS-работы |
cluster | Несколько процессов | Изолирована | IPC | Масштабирования сервера по ядрам |
Полезный способ их различать: cluster построен поверх child_process именно для решения проблемы «масштабировать сервер по ядрам», тогда как worker_threads существует, чтобы решить проблему «выполнить CPU-тяжёлый код без блокировки», не платя за целый новый процесс.
Используйте child_process, когда вам нужно запустить другую программу — shell-команду, бинарник, скрипт на другом языке — из Node.js, или когда вы хотите полностью изолированный процесс Node.js, которым управляете через stdio/IPC.
| Метод | Описание | Сценарий использования |
|---|---|---|
spawn() | Стримит stdout/stderr | Долгие процессы, большой вывод |
exec() | Буферизует весь вывод через shell | Короткие команды, малый вывод |
execFile() | Как exec(), но без shell | Безопасный запуск известного бинарника |
fork() | spawn() + встроенный IPC для модулей Node.js | Обмен сообщениями родитель/потомок в Node.js |
exec() буферизует весь stdout/stderr потомка в памяти и вызывает колбэк только по завершении процесса, причём результат по умолчанию ограничен 1 МиБ (maxBuffer) — попытка захватить таким образом большой или неограниченный вывод обрежет его и завершит потомка. execFile() вообще не запускает shell, что также обходит риски shell-инъекции, когда часть команды приходит из пользовательского ввода.
const { spawn } = require('node:child_process');
const child = spawn('ffmpeg', ['-i', 'input.mp4', 'output.mp3']);
child.stdout.on('data', data => console.log(`stdout: ${data}`));
child.stderr.on('data', data => console.error(`stderr: ${data}`));
child.on('close', code => console.log(`Process exited with code ${code}`));spawn() стримит вывод по мере его появления, вместо того чтобы буферизовать, — поэтому это правильный выбор для всего, что долго выполняется или имеет непредсказуемый размер вывода.
const { fork } = require('node:child_process');
const child = fork('./child.js');
child.send({ task: 'start', data: 42 });
child.on('message', result => console.log('Result from child:', result));fork() даёт вам готовый IPC-канал на основе structured clone (.send() / 'message'), так что вам не нужно самому парсить stdout, как пришлось бы со spawn().
- Избегайте
exec()для всего, что может выдать большой вывод, — используйтеspawn()и стримьте его вместо этого. - Всегда обрабатывайте события
'exit'и'close', чтобы замечать сбои и очищать ресурсы (открытые файловые дескрипторы, временные файлы), а не утекать ими. - Если какая-либо часть команды приходит из пользовательского ввода, предпочитайте
execFile()/spawn()с массивом аргументов вместоexec(), который выполняется через shell и уязвим к shell-инъекции, если аргументы не санитизированы.
worker_threads был добавлен, чтобы позволить выполнять JavaScript параллельно, в потоках внутри того же процесса, именно для того, чтобы вынести CPU-bound работу с главного потока, не блокируя его. В отличие от child_process, воркеры разделяют один и тот же процесс — не нужно поднимать процесс уровня ОС и нет полностью отдельного пространства памяти.
| Worker Threads | Child Process | |
|---|---|---|
| Контекст | Тот же процесс | Отдельный процесс |
| Память | Разделяема через SharedArrayBuffer | Изолирована |
| Накладные расходы на запуск | Низкие | Выше |
| Коммуникация | postMessage() (быстро) | stdio или IPC (медленнее) |
const { Worker } = require('node:worker_threads');
const worker = new Worker('./hash-worker.js', {
workerData: 'user-password',
});
worker.on('message', hash => console.log('Computed hash:', hash));
worker.on('error', err => console.error('Worker failed:', err));Хеширование паролей, преобразования изображений/видео, шифрование и парсинг очень больших полезных нагрузок в памяти — классические кандидаты: это чистая работа CPU, и запуск их на главном потоке застопорил бы все остальные запросы, которые Node.js тем временем обрабатывает.
Воркеры могут разделять память напрямую, используя SharedArrayBuffer вместе с API Atomics для безопасного конкурентного доступа, вместо копирования данных туда-сюда через postMessage(). Это избавляет от накладных расходов на сериализацию для больших буферов, но теперь вы сами отвечаете за координацию доступа — это открывает дверь к тому же классу багов с состоянием гонки (race condition), что и в традиционном многопоточном программировании.
- Worker threads предназначены для CPU-bound работы, а не для I/O — воркеру всё равно приходится ждать I/O так же, как и главному потоку, поэтому поднимать его ради запроса к базе данных или сетевого запроса добавляет накладные расходы без выгоды.
- Поднятие воркера имеет реальную стоимость. Для мелких частых задач эти накладные расходы на подготовку могут перевесить выгоду — рассмотрите пул долгоживущих воркеров (см. пакет
workerpool) вместо создания одного на задачу.
cluster решает другую проблему: один процесс Node.js использует только одно ядро CPU. Чтобы задействовать многоядерную машину для сетевого сервера, cluster форкает несколько полных копий вашего процесса (каждая под капотом — child_process), которые все слушают один и тот же порт.
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');
if (cluster.isPrimary) {
const numCPUs = availableParallelism();
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
if (worker.exitedAfterDisconnect) {
// Добровольный выход (например, worker.disconnect() или cluster.disconnect())
// — не перезапускаем.
return;
}
console.log(`Worker ${worker.process.pid} died, restarting`);
cluster.fork();
});
} else {
http
.createServer((req, res) => {
res.writeHead(200);
res.end('handled by worker ' + process.pid);
})
.listen(3000);
}Первичный процесс (cluster.isPrimary) форкает по одному воркеру на ядро и перезапускает любого воркера, который завершился неожиданно. Каждый воркер (cluster.isWorker) выполняет ваш обычный серверный код, не зная, что он один из нескольких. Флаг worker.exitedAfterDisconnect отличает добровольный выход (например, при плавной остановке через worker.disconnect() или cluster.disconnect()) от краха — без его проверки намеренная остановка продолжала бы порождать новых воркеров вместо сворачивания.
Вместо того чтобы полагаться на механизм уровня ОС, сам первичный процесс распределяет входящие соединения между своими воркерами — по умолчанию по принципу round-robin (cluster.SCHED_RR), который является значением по умолчанию на каждой платформе, кроме Windows. Это настраивается через cluster.schedulingPolicy. (Вам может встретиться старый материал, утверждающий, что это делается через SO_REUSEPORT; по умолчанию модуль cluster в Node так не делает.)
cluster.isPrimary равно true в процессе, который форкает воркеров; cluster.isWorker равно true в самих форкнутых воркерах. (В старом коде вам может встретиться cluster.isMaster — оно всё ещё работает, но это устаревший псевдоним для isPrimary.)
- Воркеры — это отдельные процессы, поэтому они не разделяют память. Сессии в памяти, кэши или ограничители частоты (rate limiter) не будут видны между воркерами — используйте внешнее хранилище вроде Redis для всего, что должно быть общим.
- Всегда обрабатывайте событие
'exit'воркера и решайте, перезапускать ли его; необработанный крах тихо снижает ёмкость вашего сервера. Проверяйтеworker.exitedAfterDisconnect, чтобы плавную остановку не приняли за крах и не перезапускали воркеров бесконечно. - В production большинство команд используют менеджер процессов (PM2, systemd или оркестратор контейнеров) вместо того, чтобы вручную писать логику перезапуска/перезагрузки напрямую через
cluster.
Быстрый гид по решению:
- Запускаете внешнюю программу или скрипт на другом языке? →
child_process(spawn/execFile). - Запускаете другой Node.js-скрипт как изолированный процесс со структурированным обменом сообщениями? →
child_process.fork(). - CPU-bound JavaScript блокирует ваш event loop (хеширование, обработка изображений, большие вычисления)? →
worker_threads. - Хотите задействовать все ядра CPU, чтобы обрабатывать больше трафика на сервер? →
cluster(или менеджер процессов/оркестратор, который делает эквивалент на уровне инфраструктуры).
Они не взаимоисключающи. Распространённый паттерн для чего-то вроде сервиса загрузки изображений — комбинировать все три: cluster, чтобы распределять входящие запросы по ядрам, и worker_threads (или child_process) внутри каждого воркера, чтобы выполнять собственно обработку изображений, не блокируя event loop этого воркера.