日本語版
最新ニュース
科学&テクノロジー

Node で新しいプロセスを生成するのがなぜこんなに遅いのでしょうか?

マックス・マクドネル の上 2024年7月19日 Val Townでは、コードをDenoプロセスで実行しています。最近、負荷がかかった状態では、Val TownのNodeサーバー1台あたりのスポーン数が40を超えないことに気付きました。メインスレッドがブロックされて、呼び出し時間の30%が費やされます。 spawnなぜこんなに遅いのでしょうか? もっと速くすることはできますか? このパターンをシミュレートするには、リクエストごとに新しいプロセスを生成する HTTP サーバーを作成します。次のようになります。 import { spawn } from "node:child_process";import http from "node:http"; .createServer((req, res) => spawn("echo", ["hi"]).stdout.pipe(res)) Goでも同様の実装を書いてみます(ここ) と Rust (ここ) を作成し、この例を Node、Deno、Bun で実行します。 私はこれらすべてを、8つのvCPUと32GBのRAMを搭載したHetzner CCX33で実行しています。ベンチマークは 爆撃手 同じマシンで実行しています。各サーバーのベンチマークを実行するコマンドは次のとおりです。 bombardier -c…

Node で新しいプロセスを生成するのがなぜこんなに遅いのでしょうか?

1722811663
2024-08-02 12:14:30


の上

Val Townでは、コードをDenoプロセスで実行しています。最近、負荷がかかった状態では、Val TownのNodeサーバー1台あたりのスポーン数が40を超えないことに気付きました。メインスレッドがブロックされて、呼び出し時間の30%が費やされます。 spawnなぜこんなに遅いのでしょうか? もっと速くすることはできますか?

このパターンをシミュレートするには、リクエストごとに新しいプロセスを生成する HTTP サーバーを作成します。次のようになります。

import { spawn } from "node:child_process";

import http from "node:http";

.createServer((req, res) => spawn("echo", ["hi"]).stdout.pipe(res))

Goでも同様の実装を書いてみます(ここ) と Rust (ここ) を作成し、この例を Node、Deno、Bun で実行します。

私はこれらすべてを、8つのvCPUと32GBのRAMを搭載したHetzner CCX33で実行しています。ベンチマークは 爆撃手
同じマシンで実行しています。各サーバーのベンチマークを実行するコマンドは次のとおりです。
bombardier -c 30 -n 10000 http://localhost:800130 の接続で合計 10,000 件のリクエスト。ベンチマークを実行する前に各サーバーを事前ウォームアップします。Go v1.22.2、Rust v1.77.2、Node v22.3.0、Bun 1.1.20、および Deno 1.44.2 を使用しています。

結果は次のとおりです。

言語/ランタイム 要件 指示
ノード 651 node baseline.js
ない 2,290 deno run --allow-all baseline.js
パン 2,208 bun run baseline.js
行く 5,227 go run go/main.go
休憩(東京) 5,466 cd rust && cargo run --release

はい、Node は遅いです。Deno と Bun はこれを高速化する方法を見つけ出し、コンパイルされたスレッド プール言語はさらに高速になりました。

ノードの spawn パフォーマンスは明らかに悪いようです。 このスレッド 興味深い記事でした。私のテストでは、その記事の時点から状況は改善されていますが、Node は依然として、Spawn 呼び出しごとにメイン スレッドをブロックするのに非常に多くの時間を費やしています。

Bun または Deno に切り替えると、この問題は大幅に改善されます。これは素晴らしいことですが、Node を使って改善してみましょう。

ノード cluster モジュール

最も簡単なのは、Node.jsを使ってプロセスを増やし、プロセスごとにhttpサーバーを実行することです。 cluster モジュール。次のようになります。

import { spawn } from "node:child_process";

import http from "node:http";

import cluster from "node:cluster";

import { availableParallelism } from "node:os";

for (let i = 0; i availableParallelism(); i++) cluster.fork();

.createServer((req, res) => spawn("echo", ["hi"]).stdout.pipe(res))

Nodeはここでプロセス間でネットワークソケットを共有するので、すべてのプロセスが :8001 リクエストはラウンドロビン方式でルーティングされます。

私にとってこのアプローチの主な問題は、各 HTTP サーバーが独自のプロセスで分離されていることです。これらのプロセス間で共有する必要があるメモリ内キャッシュやグローバル状態を管理する場合、これは事態を複雑にする可能性があります。理想的には、JavaScript のシングル スレッド実行モデルを維持しながら、スポーンを高速化する方法を見つけます。

結果は次のとおりです。

言語/ランタイム 要件 指示
ノード 1,766 node cluster.js
ない 2,133 deno run --allow-all cluster.js
パン 該当なし 「node:cluster はまだ Bun に実装されていません」

すごく奇妙です。Deno は遅く、Bun はまだ動作せず、Node は大幅に改善されましたが、もっと高速になることを期待していました。

ここで速度が少し向上したとわかってよかったです。今はそれより先に進みます。

スポーン呼び出しをワーカースレッドに移動する

もし、 spawn 呼び出しがメインスレッドをブロックしているので、ワーカースレッドに移動しましょう。

これが私たちの worker-threads/worker.js コードです。コマンドとIDのメッセージをリッスンします。それを実行して結果を返します。 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.js8 つのワーカー スレッドを作成します。リクエストを処理するときは、スレッドにメッセージを送信して spawn 呼び出しを行い、出力を返します。応答が返されたら、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();

// Emit messages from the worker to the EventEmitter by id.

worker.on("message", ([id, msg]) => ee.emit(id, msg));

// Spawn 8 worker threads.

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();

// Send and wait for our response.

worker.worker.postMessage([id, "echo", "hi"]);

return new Promise((resolve) => {

worker.ee.once(id, (msg) => {

.createServer(async (_, res) => {

let resp = await spawnInWorker();

assert.equal(resp, "hin"); // no cheating!

結果!

言語/ランタイム 要件 指示
ノード 426 node worker-threads/index.js
ない 3,601 deno run --allow-all worker-threads/index.js
パン 2,898 bun run worker-threads/index.js

Node は遅いです! そうですね、おそらくスレッドを使用して Node のボトルネックを回避しているわけではないようです。つまり、ワーカー スレッドとの調整というオーバーヘッドを追加して同じ作業を行っていることになります。残念です。

Deno はこれを気に入っており、Bun はこれをもう少し気に入っています。一般的に、Bun と Deno がここで大きな改善を見ていないのはうれしいことです。彼らはすでに、実行スレッドから sycall オーバーヘッドを排除することに成功しています。

前進。

Spawn 呼び出しを子プロセスに移動する

スレッドが機能しない場合は、子プロセスを使用して作業を実行しましょう。プロセスを生成するためにプロセスを生成しますが、メイン スレッドから少数のワーカー プロセスを生成し、それらの間で作業を分散します。この方法では、メイン スレッドの起動時にのみ生成コストを支払います。

これは非常に簡単です。ワーカースレッドを、 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 () => {

// Send and wait for our response.

- 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

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]);

いいですね。そして結果は次の通りです。

言語/ランタイム 要件 指示
ノード 2,209 node child-process/index.js
ない 3,800 deno run --allow-all child-process/index.js
パン 3,871 bun run worker-threads/index.js

全体的に高速化が進んでいます。Deno と Bun が Rust/Go の速度に達するのを妨げているボトルネックが何なのか、非常に興味があります。その原因を突き止める方法について提案があれば、ぜひ教えてください。

ここで面白いのは、Node と Bun を混在させることができることです。Bun は Node IPC プロトコルを実装しているので、Node を設定して Bun の子プロセスを生成することができます。試してみましょう。

更新する fork 引数を使用する bun Node ではなくバイナリを使用します。

const worker = fork("./child-process/worker.js", {

execPath: "/home/maxm/.bun/bin/bun",

言語/ランタイム 要件 指示
ノード + バン 3,853 node child-process/index.js

はあ、すごい。メインスレッドで Node を使って、Bun のパフォーマンスを活用できるんですね。

スタジオ

ログ。これまでの実装では、ログ出力は最小限であると想定していましたが、大量のログ出力があった場合はどうなるでしょうか?ログは次のように送信できます。 process.sendただし、出力バイトが JSON にシリアル化される場合、コストがかなり高くなります。

私はこのウサギの穴に多くの時間を費やしました。私が試したことの大まかな要約は次のとおりです。

  1. プロセス間でファイル記述子を渡します。stdout/err を親プロセスに渡すようなものです。私はこれをいくつかの方法で試しましたが、書き込まれたすべてのバイトを常にキャプチャするように動作させることはできませんでした。
  2. ただ使っているだけ process.sendこれは機能しますが、パフォーマンスが優れているのは
    serialization: "advanced" シリアル化せずにバイトを送信できるようにします。これは Deno と Bun では機能しません。
  3. 私はペアを作りました 抽象ソケット 各 spawn 呼び出しに対して、ソケット経由でログを送信します。これは、ソケットの設定に時間がかかりすぎるため、意味がありません。

また、抽象ソケットはクレイジーです。 Unix ドメイン ソケット (例)というファイルがあります something.sock そして、ネットワークアドレスと同じように、それをリッスンして接続することができます。Unixソケットを使用し、ファイル名がヌルバイトで始まる場合、 foo ソケットはファイルシステム上に存在せず、使用されなくなると自動的に削除されます。奇妙ですね! クールですね!

これらすべてのテストを行った結果、非常にうまく機能する 2 つのアプローチが見つかりました。

  1. プロセスのプールを設定する .fork() また、ログを送信するために、それぞれに個別の抽象ソケットを設定します。
  2. 使用するだけ process.send しかし、使用 serialization: "advanced"

どのように機能するか見てみましょう。

大量のログを出力するものが必要になります。そこで、 main.c Sqliteのソースからファイルを取得します。これは163KBのファイルです。次のコマンドを実行します。 cat main.c
印刷します。

これが私たちの baseline.js もう一度そのアップデートを行ってください:

import { spawn } from "node:child_process";

import http from "node:http";

.createServer((_, res) => spawn("cat", ["main.c"]).stdout.pipe(res))

Go と Rust のコードも更新しました。どのように動作するか見てみましょう。

言語/ランタイム 要件 指示
ノード 374 node baseline.js
ない 667 deno run --allow-all baseline.js
パン 1,374 bun run baseline.js
行く 2,757 go run go/main.go
休憩(東京) 3,535 cd rust && cargo run --release

興味深いですね。以前のベンチマークと比較して、Bun と Rust がここでリードしているのは素晴らしいことです。Node は依然として非常に遅く、Deno はこのワークロードに驚くほど不満を抱いています。

次に、抽象ソケット通信チャネルの実装を試してみましょう。かなり複雑になってきているので、ここでは投稿しませんが、 ここを見てください

言語/ランタイム 要件 指示
ノード 1,336 node child-process-comm-channel/index.js
ノード + バン 2,635 node child-process-comm-channel/index.js
ない 862 deno run --allow-all child-process-comm-channel/index.js
パン 1,833 bun child-process-comm-channel/index.js

ハハ。Node+Bun が bun 単独よりも高速であるというランダムなベンチマーク結果をいくつか見たことがありますが、最終的な実行では決して上回りませんでした。

Deno の結果は非常に不可解です。この例を実装する際に、応答を文字列としてバッファリングするという「バグ」がありました。これを修正した diff を以下に示します。

@@ -88,9 +88,8 @@ const spawnInWorker = async (res) => {

worker.child.send([id, "spawn", ["cat", ["main.c"]]]);

worker.ee.on(id, (msg, data) => {

if (msg == MessageType.STDOUT) {

- resp += data.toString();

if (msg == MessageType.STDOUT_CLOSE) {

この「修正」により、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]));

言語/ランタイム 要件 指示
ノード 1,179 node child-process-send-logs/index.js

非常に優れています。Node のみをターゲットにしている場合は、おそらく実用的な選択肢です。

負荷分散

プロセス間の負荷分散に関する簡単なメモ。GoとRustの両方 複雑なスケジュールを持つ それ 仕事を効率的に分配するこれまでのところ、ワーカーを選択するときにランダムに 1 つを取得しています。

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 しかし、スポーンがプロセスプールを呼び出すファーム。私がそれを書くか、あなたが書くかもしれません。 お知らせ下さい 興味があれば。

最終的な考え

残念ながら私の知識と実験の限界に達していますが、さらにパフォーマンスを向上させるには何が必要なのでしょうか。

パフォーマンスが向上したものと向上しなかったもの、そして Deno/Bun/Node がさまざまな影響を受けたランダムな瞬間を確認するのは本当に楽しかったです。

Node と Bun を一緒に使用するのは楽しいパターンであり、それがこのような高速化につながるのはうれしいことです。Node の IPC をサポートしてください、Deno!

他にここで実験すべきことがあれば教えてください。また次回お会いしましょう 🙂

このページを編集

#Node #で新しいプロセスを生成するのがなぜこんなに遅いのでしょうか

執筆者について: nipponese

Nipponese News編集部は、国内外のニュースを日本語で分かりやすくお届けします。