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:

console.log("Process" + process.argv[2] + "executed." );

Код файла master.js:

const fs = require('fs'); const child_process = require('child_process'); for(var i=0; i<3; i++) { var workerProcess = child_process.exec('node support.js '+i, function (error, stdout, stderr) { if (error) { console.log(error.stack); console.log('Код ошибки: '+error.code); console.log('Получен сигнал: '+error.signal); } console.log('stdout: ' + stdout); console.log('stderr: ' + stderr); }); workerProcess.on('exit', function (code) { console.log('Дочерний процесс завершился, код выхода'+code); }); }

Выполните приведённый выше код, результат вывода:

$ node master.js 
子进程已退出,退出码 0
stdout: 进程 1 执行。

stderr: 
子进程已退出,退出码 0
stdout: 进程 0 执行。

stderr: 
子进程已退出,退出码 0
stdout: 进程 2 执行。

stderr: 

Анализ:

execзапустит3 дочерних процесса, выполняющихся соответственно:

  • node support.js 0
  • node support.js 1
  • node 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:

console.log("Process" + process.argv[2] + "executed." );

Код файла master.js:

const fs = require('fs'); const child_process = require('child_process'); for(var i=0; i<3; i++) { var workerProcess = child_process.spawn('node', ['support.js', i]); workerProcess.stdout.on('data', function (data) { console.log('stdout: ' + data); }); workerProcess.stderr.on('data', function (data) { console.log('stderr: ' + data); }); workerProcess.on('close', function (code) { console.log('Дочерний процесс завершился, код выхода'+code); }); }

Выполните приведённый выше код, результат вывода:

$ 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:

console.log("Process" + process.argv[2] + "Выполнить." );

Код файла master.js:

const fs = require('fs'); const child_process = require('child_process'); for(var i=0; i<3; i++) { var worker_process = child_process.fork("support.js", [i]); worker_process.on('close', function (code) { console.log('Дочерний процесс завершился, код выхода' + code); }); }

Запустите приведённый выше код, вывод:

$ node master.js 
进程 0 执行。
子进程已退出,退出码 0
进程 1 执行。
子进程已退出,退出码 0
进程 2 执行。
子进程已退出,退出码 0

cluster: многопроцессный HTTP-сервис на одном порту

Минимальный работоспособный многоядерный HTTP-сервис

Пример

// server-cluster.js
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).

Плавный перезапуск (подход с нулевым временем простоя)

Подход:Главный процесс получает сигнал перезагрузки → сначала форкает нового воркера и ждет, пока он начнет слушать → затем отключает старого воркера, ждет завершения запросов перед выходом.

Пример

// Итоговый пример (в ветке cluster.isPrimary)
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

Пример

// 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

Пример

// 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

Пример

// 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-интерфейс перекладывает тяжёлые задачи на пул процессов

Пример

// app.js
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и т. д., основной процесс не блокируется, задача вычисляется в дочернем процессе.

Другие расширения