Node.js: многопроцессность
Всем известно, что Node.js работает в однопоточном режиме, но использует событийную обработку для управления параллелизмом, что помогает нам создавать несколько дочерних процессов в системах с многоядерными CPU, тем самым повышая производительность.
Каждый дочерний процесс всегда имеет три потоковых объекта: child.stdin, child.stdout и child.stderr. Они могут совместно использовать потоки stdio родительского процесса или быть независимыми потоковыми объектами, подключёнными через конвейер.
Node предоставляет модули child_process и cluster для создания дочерних процессов.
child_processМодуль
exec— child_process.exec использует дочерний процесс для выполнения команды, буферизует вывод дочернего процесса и возвращает вывод дочернего процесса в виде параметров функции обратного вызова.
execFile— напрямую запускает исполняемый файл, немного безопаснее.
spawn— child_process.spawn создаёт новый процесс с указанными аргументами командной строки.
fork— child_process.fork — специальная форма spawn() для запуска модулей в дочернем процессе, например, fork('./son.js') эквивалентно spawn('node', ['./son.js']). В отличие от метода spawn, fork создаёт канал связи между родительским процессом и дочерним процессом для межпроцессного взаимодействия.
clusterМодуль
- На одном портуНесколько рабочих процессовСовместное прослушивание, реализующееодин процесс на ядроHTTP-сервис.
- Подходит для горизонтального заполнения нескольких ядер веб-сервисами.
- Примечание: официальная рекомендация для новых проектов — внимательно оценить (например, напрямую использовать обратный прокси + несколько процессов, или
worker_threadsдля задач с интенсивной нагрузкой на CPU), ноclusterон по-прежнему широко применим.
метод exec()
child_process.exec использует дочерний процесс для выполнения команды, буферизует вывод дочернего процесса и возвращает вывод дочернего процесса в виде параметров функции обратного вызова.
Синтаксис выглядит так:
child_process.exec(command[, options], callback)
Parameters
Описание параметров приведено ниже:
command:Строка, команда для выполнения, параметры разделяются пробелами
options: Object, может быть:
- cwd: String, текущий рабочий каталог дочернего процесса
- env: Object, пары ключ-значение переменных среды
- encoding: String, кодировка символов (по умолчанию: 'utf8')
- shell: String, командная оболочка для выполнения команды (по умолчанию: в UNIX это
/bin/sh, в ней этоcmd.exe, оболочка должна распознавать-cключи в UNIX, или/s /cв Windows. В Windows разбор командной строки должен быть совместим сcmd.exe) - timeout: Number, тайм-аут (по умолчанию: 0)
- maxBuffer: Number, максимальный буфер (двоичный), допустимый в stdout или stderr; при превышении дочерний процесс будет уничтожен (по умолчанию: 200*1024)
- killSignal: String, сигнал завершения (по умолчанию: 'SIGTERM')
- uid: Number, задаёт идентификатор пользовательского процесса
- gid: Number, задает идентификатор группы процессов
callback :функция обратного вызова, содержащая три параметра error, stdout и stderr.
Метод exec() возвращает наибольший буфер, ожидает завершения процесса и возвращает содержимое буфера сразу целиком.
Пример
Давайте создадим два js-файла: support.js и master.js.
Код файла support.js:
Код файла master.js:
Выполните приведённый выше код, результат вывода:
$ node master.js 子进程已退出,退出码 0 stdout: 进程 1 执行。 stderr: 子进程已退出,退出码 0 stdout: 进程 0 执行。 stderr: 子进程已退出,退出码 0 stdout: 进程 2 执行。 stderr:
Анализ:
execзапустит3 дочерних процесса, выполняющихся соответственно:
node support.js 0node support.js 1node support.js 2
Каждый дочерний процесс выводит данные сразу после запуска:
进程 0 执行。 进程 1 执行。 进程 2 执行。
Эта часть — этостандартный вывод (stdout)дочернего процесса; он будет буферизован и возвращён целиком, когда дочерний процесс завершитсяexecв функцию обратного вызова.
Когда дочерний процесс завершается, сначала он инициируетexitсобытие, затемexecбудет выполнена функция обратного вызова (Примечание: документация Node.js утверждает, чтоexitсобытие возникает, когда процесс завершается, иexecфункция обратного вызоваstdioвызывается после закрытия потока; обычно exit выводится перед функцией обратного вызова).
Порядок вывода не определён, потому что 3 дочерних процесса выполняются параллельно, и то, какой из них завершится первым, зависит от планировщика операционной системы.
метод spawn()
child_process.spawn создаёт новый процесс с указанными аргументами командной строки, синтаксис следующий:
child_process.spawn(command[, args][, options])
Parameters
Описание параметров приведено ниже:
command:Команда для запуска
args:Массив строковых аргументов
options Object
- cwd String, текущая рабочая директория дочернего процесса
- env Object, пары ключ-значение переменных окружения
- stdio Array|String, конфигурация stdio дочернего процесса
- detached Boolean, этот дочерний процесс станет лидером группы процессов
- uid Number, задаёт ID пользовательского процесса
- gid Number, задаёт ID группы процессов
Метод spawn() возвращает потоки (stdout & stderr), используется, когда процесс возвращает большой объём данных. Как только процесс начинает выполняться, spawn() начинает получать ответы.
Пример
Давайте создадим два js-файла: support.js и master.js.
Код файла support.js:
Код файла master.js:
Выполните приведённый выше код, результат вывода:
$ node master.js stdout: 进程 0 执行。 子进程已退出,退出码 0 stdout: 进程 1 执行。 子进程已退出,退出码 0 stdout: 进程 2 执行。 子进程已退出,退出码 0
метод fork
child_process.fork — это специальная форма метода spawn(), используемая для создания процессов. Синтаксис следующий:
child_process.fork(modulePath[, args][, options])
Parameters
Описание параметров приведено ниже:
modulePath: String, модуль для запуска в дочернем процессе
args: массив строковых аргументов
options:Object
- cwd String, текущая рабочая директория дочернего процесса
- env Object, пары ключ-значение переменных окружения
- execPath String, исполняемый файл, используемый для создания дочернего процесса
- execArgv Array, массив строковых аргументов исполняемого файла дочернего процесса (по умолчанию: process.execArgv)
- silent Boolean, если это так
true, дочернего процессаstdin,stdoutиstderrбудут связаны с родительским процессом, иначе они будут унаследованы от родительского процесса. (по умолчанию:false) - uid Number, задаёт ID пользовательского процесса
- gid Number, задаёт ID группы процессов
Возвращаемый объект, помимо всех методов экземпляра ChildProcess, также имеет встроенный канал связи.
Пример
Давайте создадим два js-файла: support.js и master.js.
Код файла support.js:
Код файла master.js:
Запустите приведённый выше код, вывод:
$ node master.js 进程 0 执行。 子进程已退出,退出码 0 进程 1 执行。 子进程已退出,退出码 0 进程 2 执行。 子进程已退出,退出码 0
cluster: многопроцессный HTTP-сервис на одном порту
Минимальный работоспособный многоядерный HTTP-сервис
Пример
import cluster from 'node:cluster';
import os from 'node:os';
import http from 'node:http';
import { pbkdf2Sync } from 'node:crypto';
if (cluster.isPrimary) {
const cpuCount = Math.max(1, os.cpus().length);
console.log(`Главный процесс ${process.pid}, запуск ${cpuCount}рабочих процессов`);
for (let i = 0; i < cpuCount; i++) cluster.fork();
cluster.on('exit', (worker, code) => {
console.warn(`Рабочий процесс ${worker.process.pid}завершился (код=${код}), перезапуск...`);
cluster.fork();
});
} else {
const server = http.createServer((req, res) => {
// Имитация вычислений, интенсивно использующих CPU
pbkdf2Sync('password', 'salt', 100_000, 64, 'sha512');
res.writeHead(200, {'content-type':'text/plain; charset=utf-8'});
res.end(`Обработано воркером ${process.pid}\n`);
});
server.listen(3000, () => {
console.log(`Рабочий процесс ${process.pid}слушает3000`);
});
}
Запустите:node server-cluster.js, несколько запросовhttp://localhost:3000увидят разныеPIDответы.
Примечание:Несколько работающих экземпляров обычно имеют перед собой обратный прокси/балансировщик нагрузки (Nginx, Envoy, K8s Service) или используют cluster для агрегации на одном порту.
WebSocket/sticky-сессии требуют "sticky session" (один и тот же клиент попадает на один и тот же воркер). Можно использовать хеширование по адресу источника в L4-балансировке нагрузки или реализовать привязку самостоятельно на уровне приложения (например, маршрутизацию по req.socket.remoteAddress).
Плавный перезапуск (подход с нулевым временем простоя)
Подход:Главный процесс получает сигнал перезагрузки → сначала форкает нового воркера и ждет, пока он начнет слушать → затем отключает старого воркера, ждет завершения запросов перед выходом.
Пример
process.on('SIGUSR2', async () => {
console.log('Получен SIGUSR2, запускается плавный перезапуск');
const workers = Object.values(cluster.workers ?? {});
// 1) Запустить новые
const fresh = cluster.fork();
await new Promise(r => fresh.once('listening', r));
// 2) Завершить старые по одному
for (const w of workers) {
w?.disconnect();
// Принудительно завершить, если не завершился по таймауту
setTimeout(() => w?.process.kill('SIGKILL'), 5000);
}
});
В Windows нет SIGUSR2, вы можете использовать интерфейс управления HTTP / RPC, чтобы вызвать перезагрузку.
Создайте свой собственный "пул процессов": управляйте параллелизмом и повторно используйте дочерние процессы
Когда у вас есть большое количество независимых задач CPU (например, пакетное сжатие, шифрование, парсинг краулера), частые затраты на fork/spawn высоки; в этом случае вам нужен пул процессов, чтобы повторно использовать несколько дочерних процессов, как "пул потоков" для ограничения параллелизма.
Файл:pool/worker.js
Пример
process.on('message', async (msg) => {
if (msg.type === 'task') {
const { id, payload } = msg;
// Имитация тяжёлой задачи (Fibonacci)
const fib = (n) => (n <= 1 ? n : fib(n-1) + fib(n-2));
const result = fib(payload.n);
process.send({ type: 'done', id, result });
}
});
Файл:pool/index.js
Пример
import { fork } from 'node:child_process';
import os from 'node:os';
export class ProcessPool {
constructor({ file, size = Math.max(1, os.cpus().length - 1) } = {}) {
this.file = file;
this.size = size;
this.idle = [];
this.busy = new Map(); // worker -> taskId
this.queue = [];
for (let i = 0; i < size; i++) this._spawn();
}
_spawn() {
const w = fork(this.file);
w.on('message', (m) => {
if (m?.type === 'done') {
const cb = this.callbacks.get(m.id);
if (cb) cb.resolve(m.result);
this.callbacks.delete(m.id);
this._markIdle(w);
this._drain();
}
});
w.on('exit', () => {
// Автозаполнение пула
this.busy.delete(w);
const idx = this.idle.indexOf(w);
if (idx >= 0) this.idle.splice(idx, 1);
this._spawn();
});
if (!this.callbacks) this.callbacks = new Map();
this.idle.push(w);
}
_markIdle(w) {
this.busy.delete(w);
if (!this.idle.includes(w)) this.idle.push(w);
}
_acquire() {
return this.idle.length ? this.idle.shift() : null;
}
_drain() {
while (this.queue.length && this.idle.length) {
const { id, payload, resolve, reject } = this.queue.shift();
const w = this._acquire();
this.busy.set(w, id);
this.callbacks.set(id, { resolve, reject });
w.send({ type: 'task', id, payload });
}
}
runTask(payload) {
const id = Math.random().toString(36).slice(2);
return new Promise((resolve, reject) => {
this.queue.push({ id, payload, resolve, reject });
this._drain();
});
}
close() {
for (const w of this.idle) w.kill();
for (const w of this.busy.keys()) w.kill();
}
}
Файл:pool/demo.js
Пример
import { ProcessPool } from './index.js';
const pool = new ProcessPool({ file: './pool/worker.js', size: 4 });
const tasks = Array.from({ length: 10 }, (_, i) => pool.runTask({ n: 35 + (i % 3) }));
const t0 = Date.now();
const results = await Promise.all(tasks);
console.log('Result:', results);
console.log('Elapsed time(ms):', Date.now() - t0);
await new Promise(r => setTimeout(r, 100)); // Дождаться сброса сообщений
pool.close();
Пояснение:
- Оберните "tasks" как сообщения, пул процессов отвечает за назначение и повторное использование воркеров.
- В реальном бизнесе вы можетепарсинг/транскодирование/сжатиеинкапсулировать в
worker.js. - ПримечаниеТайм-аут задачи、повторная попытка、идемпотентностьиобратное давление(ограничение длины очереди).
Практический пример: HTTP-интерфейс перекладывает тяжёлые задачи на пул процессов
Пример
import http from 'node:http';
import { ProcessPool } from './pool/index.js';
const pool = new ProcessPool({ file: './pool/worker.js', size: 4 });
const server = http.createServer(async (req, res) => {
if (req.url?.startsWith('/fib?')) {
const url = new URL(req.url, 'http://localhost');
const n = Number(url.searchParams.get('n') || 35);
try {
const result = await pool.runTask({ n });
res.writeHead(200, {'content-type': 'application/json'});
res.end(JSON.stringify({ pid: process.pid, n, result }));
} catch (e) {
res.writeHead(500); res.end('error');
}
} else {
res.writeHead(404).end('Не найдено');
}
});
server.listen(3000, () => console.log('http://localhost:3000'));
Посетите: /fib?n=38и т. д., основной процесс не блокируется, задача вычисляется в дочернем процессе.
Другие расширения