Мы в Val Town выполняем ваш код в процессах Deno. Недавно мы заметили, что под нагрузкой отдельно взятый Node-сервер Val Town не может породить более 40 процессов. На протяжении 30% процессорного времени главный поток остаётся заблокирован вызовами к spawn. Почему так медленно? Можно ли как-нибудь ускорить эту работу?

Чтобы воспроизвести паттерн этой ситуации, напишем HTTP-сервер, порождающий новый процесс в ответ на каждый запрос. Примерно так:

import { spawn } from "node:child_process";
import http from "node:http";
http
  .createServer((req, res) => spawn("echo", ["hi"]).stdout.pipe(res))
  .listen(8001);

Мы уже писали похожие реализации на Go (здесь) и на Rust (здесь), а этот пример выполним с Node, Deno и Bun.

Все эти примеры я прогоняю на процессоре Hetzner CCX33 с 8 виртуальными ядрами и 32 ГБ ОЗУ. Для расстановки бенчмарков использую bombardier, работающий на той же машине. Чтобы померять бенчмарки на каждом сервере, я выполняю команду bombardier -c 30 -n 10000 http://localhost:8001. Всего получается 10 000 запросов, проходящих по 30 соединениям. Перед выполнением бенчмарков я «прогреваю» каждый сервер. Работаю с версиями Go v1.22.2, Rust v1.77.2, Node 22.3.0, Bun 1.1.20 и Deno 1.44.2.

Вот, что у меня получилось:

Язык/среда выполнения

Запросов/сек

Команда

Node

651

node baseline.js

Deno

2 290

deno run --allow-all baseline.js

Bun

2 208

bun run baseline.js

Go

5 227

go run go/main.go

Rust (tokio)

5 466

cd rust && cargo run --release

Ладно, Node работает медленно. В Deno и Bun нашли способ ускорить эту работу, а компилируемые языки, оперирующие пулом потоков, опять оказываются гораздо быстрее.

Бросается в глаза, насколько низкая производительность у spawn в Node. Интересно было почитать эту ветку и, хотя именно в моих опытах ситуация улучшилась со времён той дискуссии, Node всё равно требуется чудовищно долго держать главный поток заблокированным, пока выполняется каждый из вызовов Spawn.

Если переключиться на Bun или Deno, то ситуация значительно улучшится. Замечательно, что это так, но давайте попытаемся улучшить и ситуацию с Node.

Модуль cluster в составе Node

Простейший выход в данном случае — порождать больше процессов и для каждого процесса выполнять http server при помощи модуля cluster, предусмотренного в Node. Вот так:

import { spawn } from "node:child_process";
import http from "node:http";
import cluster from "node:cluster";
import { availableParallelism } from "node:os";

if (cluster.isPrimary) {
  for (let i = 0; i < availableParallelism(); i++) cluster.fork();
} else {
  http
    .createServer((req, res) => spawn("echo", ["hi"]).stdout.pipe(res))
    .listen(8001);
}

Здесь Node обеспечивает разделяемость сетевого сокета между процессами, поэтому все наши процессы могут слушать порт :8001, причём, маршрутизация запросов происходит по принципу карусели.

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

Вот результаты:

Язык/Среда выполнения

Запросов/сек

Команда

Node

1 766

node cluster.js

Deno

2 133

deno run --allow-all cluster.js

Bun

n/a

”node:cluster пока не реализован в Bun”

Страннее некуда. Deno медленнее, Bun пока не работает, а Node серьёзно улучшился, но мы ожидали, что он будет ещё быстрее.

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

Передаём рабочим потокам порождающие вызовы

Если вызовы spawn блокируют главный поток, то давайте перенесём их в рабочие потоки.

Вот код worker-threads/worker.js. Мы слушаем сообщения, содержащие команду и id. Выполняем команду и отсылаем результаты обратно методом post. Здесь для удобства воспользуемся execFile, но это просто абстракция, написанная поверх spawn.

import { execFile } from "node:child_process";
import { parentPort } from "node:worker_threads";

parentPort.on("message", (message) => {
  const [id, cmd, ...args] = message;

  execFile(cmd, args, (_error, stdout, _stderr) => {
    parentPort.postMessage([id, stdout]);
  });
});

А вот файл worker-threads/index.js. Мы создаём 8 рабочих потоков. Когда хотим обработать запрос, мы отправляем потоку сообщение, приказывая ему сделать порождающий вызов и переслать его вывод обратно. Получив ответ, мы реагируем на http-запрос.

import assert from "node:assert";
import http from "node:http";
import { EventEmitter } from "node:events";
import { Worker } from "node:worker_threads";

const newWorker = () => {
  const worker = new Worker("./worker-threads/worker.js");
  const ee = new EventEmitter();
  // Выдаём сообщения от рабочего потока к EventEmitter по id.
  worker.on("message", ([id, msg]) => ee.emit(id, msg));
  return { worker, ee };
};

// Порождаем 8 рабочих потоков
const workers = Array.from({ length: 8 }, newWorker);
const randomWorker = () => workers[Math.floor(Math.random() * workers.length)];

const spawnInWorker = async () => {
  const worker = randomWorker();
  const id = Math.random();
  // Отправляем и ожидаем отклика
  worker.worker.postMessage([id, "echo", "hi"]);
  return new Promise((resolve) => {
    worker.ee.once(id, (msg) => {
      resolve(msg);
    });
  });
};

http
  .createServer(async (_, res) => {
    let resp = await spawnInWorker();
    assert.equal(resp, "hi\n"); // no cheating!
    res.end(resp);
  })
  .listen(8001);

Результаты!

Язык/среда выполнения

Запросов/сек

Команда

Node

426

node worker-threads/index.js

Deno

3,601

deno run --allow-all worker-threads/index.js

Bun

2,898

bun run worker-threads/index.js

Node медленнее! Что ж, можно предположить, что узкое место Node не обойти, если просто использовать потоки. Так что мы выполняем ту же работу, что и ранее, но при координации рабочих потоков несём дополнительные издержки. Облом.

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

Идём дальше.

Переносим порождающие вызовы в дочерние процессы

Если потоки не слишком хороши, давайте вынесем их задачи в дочерние процессы. Мы порождаем процессы ради порождения процессов, но при этом порождаем сравнительно немногочисленные рабочие процессы из главного потока и распределяем работу между ними. Таким образом, мы несём издержки за порождение лишь при пуске главного потока.

Это довольно просто. Вместо рабочих потоков у нас теперь будут процессы, порождаемые child_process.fork, а также изменится процесс отправки и получения сообщений.

$ git diff --unified=1 --no-index ./worker-threads/ ./child-process/
diff --git a/./worker-threads/index.js b/./child-process/index.js
index 52a93fe..0ed206e 100644
--- a/./worker-threads/index.js
+++ b/./child-process/index.js
@@ -3,6 +3,6 @@ import http from "node:http";
 import { EventEmitter } from "node:events";
-import { Worker } from "node:worker_threads";
+import { fork } from "node:child_process";

 const newWorker = () => {
-  const worker = new Worker("./worker-threads/worker.js");
+  const worker = fork("./child-process/worker.js");
   const ee = new EventEmitter();
@@ -21,3 +21,3 @@ const spawnInWorker = async () => {
   // Отправляем и дожидаемся отклика
-  worker.worker.postMessage([id, "echo", "hi"]);
+  worker.worker.send([id, "echo", "hi"]);
   return new Promise((resolve) => {
diff --git a/./worker-threads/worker.js b/./child-process/worker.js
index 5f025ca..9b3fcf5 100644
--- a/./worker-threads/worker.js
+++ b/./child-process/worker.js
@@ -1,5 +1,4 @@
 import { execFile } from "node:child_process";
-import { parentPort } from "node:worker_threads";

-parentPort.on("message", (message) => {
+process.on("message", (message) => {
   const [id, cmd, ...args] = message;
@@ -7,3 +6,3 @@ parentPort.on("message", (message) => {
   execFile(cmd, args, (_error, stdout, _stderr) => {
-    parentPort.postMessage([id, stdout]);
+    process.send([id, stdout]);
   });

Хорошо. А вот результаты:

Язык/Среда выполнения

Запросов/сек

Команда

Node

2 209

node child-process/index.js

Deno

3 800

deno run --allow-all child-process/index.js

Bun

3 871

bun run worker-threads/index.js

Везде хорошее ускорение, но мне очень любопытно, где же то узкое место, из-за которого Deno и Bun не могут разогнаться до скорости Rust/Go. Пожалуйста, сообщите, если у вас есть идеи, как в этом разобраться!

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

Обновим аргументы fork так, чтобы вместо Node использовался двоичный файл bun.

const worker = fork("./child-process/worker.js", {
  execPath: "/home/maxm/.bun/bin/bun",
});

Язык/Среда выполнения

Запросов/сек

Команда

Node + Bun

3 853

node child-process/index.js

Хах, круто. Теперь я могу использовать Node в главном потоке и при этом опираться на производительность Bun.

Stdio

Логи. В предыдущих реализациях предполагалось, что вывод в логи будет минимальным, но что делать, если вывода будет много? Можно было бы отправлять логи при помощи process.send, но это обойдётся нам достаточно дорого, если выходные байты будут сериализовываться в JSON.

Я много времени провёл за исследованием этой кроличьей норы. Далее кратко резюмирую, что я попробовал:

  1. Передавать дескрипторы файлов между процессами. Например, передавать stdout/err обратно в родительский процесс. Я попробовал это несколькими способами, но так и не удалось добиться, чтобы всякий раз перехватывались все записанные байты.

  2. Просто использовать process.send. Это работает, но хорошая производительность достигается только при применении опции serialization: "advanced", позволяющей передавать байты без сериализации. Такой подход не работает с Deno и Bun.

  3. Пробовал создавать пару абстрактных сокетов на каждый порождающий вызов и отправлял логи через сокет. При этом приходится слишком много времени тратить на настройку сокетов, чтобы дело того стоило.

Кроме того, абстрактные сокеты – безумная штука. Я знаком с сокетами домена, при работе с которыми у вас есть файл, именуемый (например) something.sock. Его можно слушать и подключаться к нему точно как к сетевому адресу. Оказывается, если вы пользуетесь сокетом Unix, и имя этого сокета начинается с нулевого байта, например, \0foo, то сокет не существует в файловой системе и будет автоматически удаляться, если уже не используется. Странно! Круто!

По результатам всех этих тестов остались два подхода, я остановился на двух подходах, работающих достаточно хорошо.

  1. При помощи .fork() настроить пул процессов, а также предусмотреть отдельный абстрактный сокет для каждого процесса, через который посылать логи.

  2. Просто использовать process.send, но с опцией serialization: "advanced".

Давайте посмотрим, как всё это работает на практике.

Нам нужен код, который выводил бы много логов. Так что я взял файл main.c из исходников Sqlite. Размер этого файла —163 Kб. Запустим его командой cat main.c, чтобы получить вывод в консоль.

Вернёмся к нашему файлу baseline.js, но уже после этого обновления:

import { spawn } from "node:child_process";
import http from "node:http";
http
  .createServer((_, res) => spawn("cat", ["main.c"]).stdout.pipe(res))
  .listen(8001);

Я также обновил код на Go и Rust. Давайте посмотрим, как у них дела:

Язык/Среда выполнения

Запросов/сек

Команда

Node

374

node baseline.js

Deno

667

deno run --allow-all baseline.js

Bun

1 374

bun run baseline.js

Go

2 757

go run go/main.go

Rust (tokio)

3 535

cd rust && cargo run --release

Захватывает. Круто видеть, что Bun и Rust здесь вырываются вперёд в сравнении с предыдущими бенчмарками. Node по-прежнему очень медленным, а Deno выглядит с данной рабочей нагрузкой удивительно неприглядно.

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

Язык/Среда выполнения

Запросов/сек

Команда

Node

1 336

node child-process-comm-channel/index.js

Node + Bun

2 635

node child-process-comm-channel/index.js

Deno

862

deno run --allow-all child-process-comm-channel/index.js

Bun

1 833

bun child-process-comm-channel/index.js

Хаха. Мне доводилось видеть такие результаты бенчмарков, при которых Node+Bun работает быстрее, чем Bun как таковой, но в итоговых прогонах такая практика никогда не оправдывалась.

С Deno получались такие результаты, над которыми приходилось поломать голову. Когда я реализовывал этот проект, возникал «баг», при котором отклик буферизовался как строка. Вот как я попытался это исправить:

@@ -88,9 +88,8 @@ const spawnInWorker = async (res) => {
   worker.child.send([id, "spawn", ["cat", ["main.c"]]]);
-  let resp = "";
   worker.ee.on(id, (msg, data) => {
     if (msg == MessageType.STDOUT) {
-      resp += data.toString();
+      res.write(data);
     }
     if (msg == MessageType.STDOUT_CLOSE) {
-      res.end(resp);
+      res.end();
       worker.requests -= 1;

При такой «правке» Deno сильно замедляется, а Node и Bun значительно ускоряются! Задумываюсь, в чём же дело, в более быстрой реализации toString() или в том, что при работе с res.write издержки выше?

Язык/Среда выполнения

Запросов/сек

Команда

Deno + строковый буфер

1,453

deno run --allow-all child-process-comm-channel/index.js

Странно!

Наконец, есть реализация с process.send. Она быстра, а также необыкновенно проста в реализации. Меня не слишком вдохновляет такое решение, поскольку оно медленнее, чем мне бы хотелось, не поддерживает Deno и Bun, а также здесь очень невелико поле для маневра, который позволял бы что-нибудь поправить. Однако, эта реализация глубоко практична, и её легко понять, что просто красиво. Вот исходный код worker.js, остальной код находится здесь.

import { spawn } from "node:child_process";
import process from "node:process";

process.on("message", (message) => {
  const [id, cmd, ...args] = message;
  const cp = spawn(cmd, args);
  cp.stdout.on("data", (data) => process.send([id, "stdout", data]));
  cp.stderr.on("data", (data) => process.send([id, "stderr", data]));
  cp.on("close", (code, signal) => process.send([id, "exit", code, signal]));
});

Язые/Среда выполнения

Запросов/сек

Команда

Node

1 179

node child-process-send-logs/index.js

Очень красиво и, пожалуй, наиболее практично, если вы собираетесь работать только с Node.

Балансировка нагрузки

Также кратко коснёмся распределения нагрузки между процессами. Как в Go, так и в Rust используются сложные планировщики, которые эффективно распределяют задачи. До сих пор, выбирая рабочий поток, я брал тот, который попадётся первым:

const workers = await Promise.all(Array.from({ length: 8 }, newWorker));
const randomWorker = () => workers[Math.floor(Math.random() * workers.length)];

Но здесь можно реализовать и механизм карусели, а также балансировку нагрузки в стиле наименьшего количества соединений. Вот отличная статья на эту тему.

const pickWorkerInOrder = () => workers[(count += 1) % workers.length];
const pickWorkerWithLeastRequests = () =>
  workers.reduce((selectedWorker, worker) =>
    worker.requests < selectedWorker.requests ? worker : selectedWorker
  );

К сожалению, даже со всеми этими нововведениями мне не удалось стабильно повысить производительность. Эффективность всех вариантов примерно одинакова. Может быть, такие изменения будут более полезны при более типичных рабочих нагрузках, где порождающие вызовы распределены не вполне равномерно.

Библиотека?

Учитывая всё, что мы обнаружили, кажется возможным реализовать библиотеку child_process, поверхность которой представляла бы собой такой же API, как и у node:child_process, но вызовы на порождение потоков она вытягивала бы из пула потоков. Может быть, когда-нибудь напишу об этом.

Заключение

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

Комментарии (4)


  1. m0xf
    04.08.2024 11:38

    Думаю, проблема в использовании вызова fork. При порождении процесса копируется таблица страниц виртуальной памяти. Чем больше памяти выделил процесс, тем медленнее будет работать fork. Переход на vfork решит проблему, но как я смог нагуглить, разработчики от этого отказались: https://github.com/libuv/libuv/pull/141


  1. slonopotamus
    04.08.2024 11:38
    +4

    напишем HTTP-сервер, порождающий новый процесс в ответ на каждый запрос

    Зумеры изобрели CGI?


    1. zoto_ff
      04.08.2024 11:38

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


      1. vit1251
        04.08.2024 11:38

        Это важно только в HighLoad, а когда у домашней странички всего 5 посетителей в неделю, то все эти заморочки излишние. Вопрос как понять и на каком моменте пора переходить с PHP на какие-то производительные подходы. Опять же для PHP есть всякие FastCGI. В определенный момент и Node тоже упираеться и я так понимаю, что следующий шаг это Golang и Rust.