Во всех предыдущих частях мы уже говорили о том, что Event Loop в Node.js работает в единственном основном потоке. При этом часть операций — например, чтение файлов — может выполняться в thread pool’е, специальном пуле потоков, куда Node.js отправляет тяжёлые задачи.

Но несмотря на это, весь наш JavaScript-код, а также ключевые операции вроде обработки запросов, выполняются строго в одном потоке. Именно поэтому Node.js и называют однопоточным — по крайней мере, с точки зрения приложения.

Однако в реальности у него всё же есть способы выйти за рамки одного потока. Сегодня как раз о них и поговорим.

Зачем нужна многопоточность в Node.js

Node.js изначально создавался для обработки множества одновременных I/O-запросов — например, сетевых или файловых. Тут он хорош: один поток, неблокирующий код, быстрые ответы. Но как только внутри этого потока появляется тяжёлая синхронная задача, всё — Event Loop стопорится, сервер замирает, пользователи ждут.

Чтобы как-то с этим жить, ещё с ранних версий Node.js в ядре был модуль child_process. С его помощью можно было запускать внешние команды и сторонние скрипты — например, shell-скрипт или python your_script.py. Однако общение с этими процессами было довольно ограниченным (stdin и stdout), и всё упиралось в строки и парсинг.

Потом появился метод fork, и он уже был заточен на запуск других Node.js-скриптов. Главное отличие — возможность устанавливать двустороннюю связь между родителем и потомком через IPC — межпроцессное взаимодействие. То есть можно передавать JavaScript-объекты с помощью process.send() и ловить их на стороне дочернего процесса. Это уже похоже на то, как хочется строить многопоточность: можно делегировать тяжёлую задачу во «второй процесс» и не блокировать основной поток.

Вот пример:

// main.js
const { fork } = require('child_process');
const worker = fork('./worker.js');

worker.send({ action: 'compute', value: 42 });

worker.on('message', (result) => {
  console.log('Результат от воркера:', result);
});
// worker.js
process.on('message', (msg) => {
  if (msg.action === 'compute') {
    const res = сalculation(msg.value);
    process.send(res);
  }
});

function сalculation(num) {
  return num * 2;
}

В этом примере основное приложение остаётся отзывчивым, а тяжёлая работа уходит в отдельный процесс. Однако и здесь всё приходилось делать вручную: запускать нужное количество воркеров, самому распределять задачи, следить, чтобы кто-то не умер, и при этом не забывать, что каждый процесс ест свою память. С ростом нагрузки всё это становилось болью, и разработчикам остро не хватало более высокого уровня абстракции.

Так и появился cluster — инструмент, который взял на себя менеджмент процессов.

Cluster

Модуль cluster стал первым официальным способом масштабировать серверное приложение в Node.js. Он использует child_process.fork() под капотом, но оборачивает его в более удобное API, снимая с разработчика головную боль по ручному менеджменту воркеров.

Главная фишка cluster — он позволяет запускать несколько процессов, которые слушают один и тот же порт. Это значит, что мы можем поднять сервер в каждом воркере, а Node.js сам распределит входящие запросы между ними.

Кроме того, cluster умеет:

  • следить за состоянием воркеров,
  • перезапускать упавшие процессы,
  • управлять их количеством на лету.

Вот простой пример, как это выглядит:

const cluster = require('cluster');
const os = require('os');
const http = require('http');

if (cluster.isMaster) {
  const cpuCount = os.cpus().length;

  console.log(`Master запущен. Форкаем ${cpuCount} воркеров...`);
  for (let i = 0; i < cpuCount; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker) => {
    console.log(`Воркер ${worker.process.pid} умер. Перезапускаем...`);
    cluster.fork();
  });

} else {
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end(`Ответ с воркера ${process.pid}\\n`);
  }).listen(3000);

  console.log(`Воркер ${process.pid} слушает порт 3000`);
}

Каждый запущенный воркер — полноценный процесс со своим PID, но все они отвечают на одном и том же порту. Мастер-процесс не занимается обработкой запросов — он просто управляет рабочими и следит, чтобы всё было стабильно.

Однако у cluster есть и свои ограничения. Несмотря на все плюсы, он работает не с потоками, а с полноценными отдельными процессами, а это накладывает определённые ограничения на архитектуру и производительность.

Во-первых, у каждого воркера — своя изолированная память. Это значит, что если мы хотим, чтобы все воркеры использовали, скажем, общий кэш или доступ к общим данным — мы не сможем просто создать объект и расшарить его между ними. Придётся писать отдельный механизм обмена — например, через файл или базу данных. Это добавляет как сложность, так и задержки.

Во-вторых, вся коммуникация между мастер-процессом и воркерами идёт через сериализацию. Когда мы используем worker.send({ user }), объект превращается в сериализованную структуру, пересылается по каналу, а затем десериализуется на другой стороне. Это значит:

  • нельзя передавать функции, ссылки на классы, замыкания и т.д.,
  • есть накладные расходы на упаковку и распаковку данных,
  • сложные или большие структуры могут передаваться с задержками.

И наконец, каждый воркер — это полноценный процесс, со своими ресурсами. Он ест память, грузит CPU и создаёт нагрузку на систему. Если мы запускаем по воркеру на каждое ядро — всё ок. Но если у нас появляются десятки или сотни таких воркеров (например, для обработки каждой задачи в отдельном процессе) — память может закончиться очень быстро. В отличие от потоков, процессы не делят heap, и каждое дублирование чего-то «общего» — это дополнительные байты в памяти.

Поэтому cluster идеально подходит для масштабирования HTTP-серверов по ядрам, но становится менее удобным, когда нужно:

  • запускать множество параллельных задач,
  • быстро обмениваться данными,
  • экономно расходовать ресурсы внутри одного приложения.

Вот тут на сцену выходят worker_threads, которые решают эти проблемы, предлагая настоящую многопоточность внутри одного процесса.

Worker Threads

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

Каждый воркер получает собственный Event Loop, может выполнять JavaScript-код независимо и возвращать результат обратно в основной поток. Это особенно полезно в случае тяжёлых вычислений, вроде парсинга, шифрования или генерации отчётов, которые в однопоточном режиме блокировали бы всё приложение.

Вот пример, как это работает:

// main.js
const { Worker } = require('worker_threads');

function runWorker(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker.js', { workerData: data });

    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker stopped with code ${code}`));
    });
  });
}

runWorker(42).then((result) => {
  console.log('Результат от потока:', result);
});

----------------------------

// worker.js
const { parentPort, workerData } = require('worker_threads');

function heavyComputation(input) {
  const start = Date.now();
  while (Date.now() - start < 2000) {}
  return input * 2;
}

const result = heavyComputation(workerData);
parentPort.postMessage(result);

В этом примере главный поток продолжает работать, а вычисления выполняются в фоне. Потоки запускаются быстро, используют меньше памяти, чем процессы, и позволяют эффективно распараллеливать задачи. Дополнительно можно использовать SharedArrayBuffer, если нужно совместно использовать память между потоками.

Несмотря на все плюсы, worker_threads не подходят для масштабирования HTTP-серверов — каждый поток не может слушать один и тот же порт. Поэтому в задачах, связанных с обработкой HTTP-запросов и распределением трафика, по-прежнему предпочтительнее использовать cluster.

Как выбрать

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

Если нужно просто запустить внешнюю программу, вроде ffmpeg или python-скрипта, подойдёт child_process.spawn() или exec(). Если важно запустить отдельный Node.js-скрипт и наладить простое общение между процессами — поможет fork().

Когда стоит задача масштабировать сетевой сервер и использовать все ядра CPU, удобнее всего использовать cluster: он автоматически распределяет входящие соединения между воркерами и позволяет обрабатывать больше трафика без лишней боли. Но при этом каждый воркер — полноценный процесс, с отдельной памятью и своими ресурсными затратами.

А вот если речь идёт о тяжёлых вычислениях, которые могут блокировать Event Loop, но запускать отдельный процесс ради этого — слишком жирно, разумнее использовать worker_threads. Они запускаются быстрее, потребляют меньше памяти и позволяют эффективно использовать многопоточность внутри одного процесса.

Но самое важное — все эти инструменты скорей всего не нужны, если приложение работает с I/O, базами данных или API и не упирается в CPU. Node.js прекрасно справляется с асинхронными задачами в однопоточном режиме, и добавление потоков или процессов «просто потому что» — чаще вред, чем польза.

Выводы

Мы рассмотрели, как Node.js эволюционировал от использования отдельных процессов через child_process и fork до более удобного инструмента — cluster, а затем перешли к настоящей многопоточности с помощью worker_threads.

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