Event Loop крутится, Node.js мутится. Часть 5. Настоящая многопоточность.

Event Loop крутится, Node.js мутится. Часть 5. Настоящая многопоточность.

Anastasia Kotova

Предыдущие части

Event Loop крутится, Node.js мутится. Часть 1. Браузер VS сервер.

Event Loop крутится, Node.js мутится. Часть 2. Нюансы работы Event Loop.

Event Loop крутится, Node.js мутится. Часть 3. За пределами Event Loop.

Event Loop крутится, Node.js мутится. Часть 4. HTTP-сервер.


Во всех предыдущих частях мы уже говорили о том, что 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.

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


Следующие части

Event Loop крутится, Node.js мутится. Часть 6. Профилировать и замерять.

Report Page