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 にシリアル化される場合、コストがかなり高くなります。
私はこのウサギの穴に多くの時間を費やしました。私が試したことの大まかな要約は次のとおりです。
- プロセス間でファイル記述子を渡します。stdout/err を親プロセスに渡すようなものです。私はこれをいくつかの方法で試しましたが、書き込まれたすべてのバイトを常にキャプチャするように動作させることはできませんでした。
- ただ使っているだけ
process.sendこれは機能しますが、パフォーマンスが優れているのは
serialization: "advanced"シリアル化せずにバイトを送信できるようにします。これは Deno と Bun では機能しません。 - 私はペアを作りました 抽象ソケット 各 spawn 呼び出しに対して、ソケット経由でログを送信します。これは、ソケットの設定に時間がかかりすぎるため、意味がありません。
また、抽象ソケットはクレイジーです。 Unix ドメイン ソケット (例)というファイルがあります something.sock そして、ネットワークアドレスと同じように、それをリッスンして接続することができます。Unixソケットを使用し、ファイル名がヌルバイトで始まる場合、