Node.js のAPIで、3秒かかる計算を2件続けて受けた。その間のヘルスチェックは、計測すると6秒たってようやく返った。「プロセスが落ちた?」とログを見てもエラーは無い。一瞬で終わる処理なのに、順番が回ってこないだけだ。
ソルバや画像変換のような数秒の計算は、別スレッド(worker_threads)、別プロセス(child_process)、別のサービスやジョブのどれかに置くことになる。この記事はその3つを比べ、worker_threads の最小構成、本数の決め方と測り方、AIエージェントへの指示までまとめる。
なぜ他のリクエストまで止まるのか
Node はメインスレッド1本のイベントループでリクエストを順に捌く。I/O待ちなら順番を譲れるが、同期の計算は終わるまで譲らない。
async 関数で包んでも同じだ。中身は最初の await まで同期に走る。手元で500msの同期計算を async 関数で包むと、10ms後のタイマーが500ms後まで動かなかった。wasm 製のソルバのように1回の呼び出しが数秒戻らない処理は、区切って譲ることもできない。
置き場所の3択を比べる
| worker_threads | 子プロセス(child_process) | 別サービス・ジョブ | |
|---|---|---|---|
| 分離の単位 | 同じプロセスの別スレッド | 別プロセス(メモリもV8も別) | 別のコンテナ・マシン |
| メモリ | 1本ごとに増え、プロセスの上限を共有する | 1つごとにNode1つ分 | 計算側だけ大きくできる |
| 起動にかかる時間(手元の一例) | 約14ms | 約29ms | コンテナの起動・キューの待ち |
| データの受け渡し | structured clone でコピー。ArrayBuffer は移せる | JSON(既定)か advanced でシリアライズ | ネットワーク越し。大きいものは保存先を介す |
| 計算が落ちたとき | JSの例外・ヒープ不足はその worker だけ終わる。terminate() で外から止められる。ネイティブのクラッシュとプロセスのメモリ不足は全体を落とす | 子だけ終わる。ネイティブコードの中で止まっていても、fork の timeout のシグナルで止められる | APIの側は無傷 |
| 応答の形 | 同じHTTP応答で返せる | 同じHTTP応答で返せる | 受付番号を返し、結果は後で取らせる形になりやすい |
| 運用で増えるもの | プールのコードだけ | 子の監視と後始末 | キュー・リトライ・デプロイ先 |
起動時間は Node 24・Apple Silicon の Mac で空のまま7回起動した中央値。
worker_threads:一番軽いが、メモリと落ち方を親と共有する
公式ドキュメントは、worker はCPUを使うJavaScriptの処理に向き、I/Oにはほとんど効かないと書いている。デプロイは今のままでいい。JS・wasm の実行中でも terminate() で外から止められる。
弱いのはメモリだ。resourceLimits の上限はJSエンジンのヒープだけが対象で、ArrayBuffer や wasm のメモリは外にある。プロセス全体がメモリ不足になれば、APIごと落ちる。
受け渡しのコピーも無料ではない。同じプロセスの中のほうが軽そうなのに、手元で20万件のオブジェクト配列を往復させると、worker は約170ms、子プロセス(JSON)は約85msだった。大きいデータは ArrayBuffer に詰めて移す(transfer)か、worker の側で読み込む。
子プロセス:ネイティブのクラッシュでも親は無傷
fork() で起こした子は、通信の経路以外は親から独立し、メモリもV8も別だ。ネイティブアドオンのクラッシュで子が落ちてもAPIは残り、ネイティブコードの中で止まっても timeout のシグナルで止められる。
代わりに子1つごとにNode1つ分のメモリを持ち、公式ドキュメントは多数の起動を勧めていない。worker と同じくプールで使い回す。
別サービス・ジョブ:計算だけ増やせるが、APIの形が変わる
外に出せば、API側と計算側を別々のサイズ・台数にできる。1件が数十秒以上かかる、件数の山が大きい、計算側だけ大きなメモリが要る、のどれかならこれだ。代わりにリトライと二重実行の防止が要る。
どれを選ぶか
迷ったら worker_threads のプールからでいい。呼び出しを1か所差し替えるだけで済み、1台で捌けなくなってから別サービスを考えればいい。
worker_threads で動かす最小構成
1. 前提を確かめる
perf_hooks の monitorEventLoopDelay() で遅延の分布を取るか、重い処理の最中にヘルスチェックを叩き、本当に塞がれているかを測る。入力の組み立てや結果の変換はメインスレッドに残るので、どこまで worker に出すかもここで決める。
2. worker の側
// heavy-worker.mjs
import { parentPort } from 'node:worker_threads';
import { heavy } from './heavy.mjs'; // 重い計算の本体
// 1 メッセージ = 1 件。heavy が投げた例外は worker の 'error' になる
parentPort.on('message', (input) => {
parentPort.postMessage(heavy(input));
});
worker の中で捕まらなかった例外は、親の Worker に 'error' として届き、その worker は終了する。
3. プール
// pool.mjs
import { Worker } from 'node:worker_threads';
// 本数は環境ごとに渡す。未設定なら 1、正の整数以外は起動時に落とす
export function maxWorkers(env = process.env) {
const raw = env.HEAVY_MAX_WORKERS;
if (raw === undefined || raw === '') return 1;
const n = Number(raw);
if (!Number.isInteger(n) || n < 1) {
throw new Error(`HEAVY_MAX_WORKERS must be a positive integer, got "${raw}"`);
}
return n;
}
export class WorkerPool {
#idle = [];
#waiting = [];
#size = 0;
constructor(file, max) {
this.file = file;
this.max = max;
}
async run(input) {
const worker = await this.#acquire();
try {
const result = await call(worker, input);
this.#release(worker);
return result;
} catch (error) {
// 例外やクラッシュを起こした worker は使い回さない
this.#size--;
worker.terminate();
this.#wakeNext();
throw error;
}
}
#acquire() {
if (this.#idle.length > 0) return Promise.resolve(this.#idle.pop());
if (this.#size < this.max) return Promise.resolve(this.#spawn());
return new Promise((resolve) => this.#waiting.push(resolve));
}
#release(worker) {
const next = this.#waiting.shift();
if (next) return next(worker);
worker.unref(); // 待機中の worker がプロセスの終了を止めないように
this.#idle.push(worker);
}
#wakeNext() {
const next = this.#waiting.shift();
if (next) next(this.#spawn());
}
#spawn() {
this.#size++;
return new Worker(this.file);
}
}
function call(worker, input) {
return new Promise((resolve, reject) => {
const done = (fn) => (value) => {
worker.off('message', onMessage).off('error', onError).off('exit', onExit);
fn(value);
};
const onMessage = done(resolve);
const onError = done(reject);
const onExit = done((code) => reject(new Error(`worker exited with code ${code}`)));
worker.ref();
worker.on('message', onMessage).on('error', onError).on('exit', onExit);
worker.postMessage(input);
});
}
設計の芯は4つある。
- worker は使い回す。 モジュールや wasm の読み込みが worker ごとに1回で済む。公式も1件ごとの起動ではなくプールを勧めている
- 上限を超えた依頼は待たせる。 断って503を返すかは要件で決める
- 失敗した worker は捨てる。 wasm が途中で異常終了した後の状態は信用できない
- 本数は環境変数で渡す。 未設定なら1本で、今の挙動と同じ同時実行数になる
このプールは、重い処理が同期関数で返信の後に何も残さない前提だ。待機中に例外が起きうるなら、常設の 'error' を付けてプールから外す。
自分でプールを書く代わりに Piscina も使える。ただ上限の既定値は os.availableParallelism() の1.5倍なので、メモリから決めた本数を maxThreads に明示する。
4. APIから呼ぶ
// server.mjs
import http from 'node:http';
import { heavy } from './heavy.mjs';
import { WorkerPool, maxWorkers } from './pool.mjs';
const pool = new WorkerPool(new URL('./heavy-worker.mjs', import.meta.url), maxWorkers());
const inline = process.env.MODE === 'inline'; // 比較用: メインスレッドで計算する
http
.createServer(async (req, res) => {
if (req.url === '/health') return res.end('ok');
const input = { ms: 3000, fail: req.url === '/heavy?fail=1' };
try {
const out = inline ? heavy(input) : await pool.run(input);
res.end(JSON.stringify(out));
} catch (error) {
res.statusCode = 500;
res.end(error.message);
}
})
.listen(Number(process.env.PORT ?? 3000));
再現用の heavy.mjs は、指定したミリ秒だけ while で回り、fail なら例外を投げる関数だ。
worker の本数は vCPU とメモリで決めて測る
本数の上限を決めるのは2つだ。
- vCPU。 CPUを使い切る計算は、vCPUより多く並べても速くならない。1 vCPUで2本にすると、2倍速くなるはずがCPUを分け合うだけでメモリが増える
- メモリ。 1本ごとにV8の実行環境と読み込んだモジュールの分が増える。手元では空の worker でもRSSが1本あたり約10〜15MB増えた
os.availableParallelism() は、公式が os.cpus().length の代わりに勧める関数だ。ただ libuv のドキュメントが挙げる Linux の判定材料はCPUアフィニティだけだ。コンテナのCPUクォータは libuv 1.49 以降で見るようになり、1 CPU未満は 1.52 で直ったばかりなので、Node の版で値が変わりうる。本数は環境変数で明示し、起動時にこの関数の値と設定した本数をログに出す。
本番と同じvCPU・メモリの環境で、次の3つを測る。
| 測るもの | 見るところ |
|---|---|
| 同時N件の完了時間 | 1本では直列、本数を増やすと並列になるか |
| 他のエンドポイントの応答時間 | 計算中もp50と最大が悪化しないか |
| メモリのピーク | 上限に対して余裕が残るか |
計測は、重い処理を同時にN件投げ、その間ヘルスチェックを0.2秒ごとに叩くだけで済む。
measure.mjs(計測スクリプト)
// measure.mjs — 使い方: node measure.mjs <同時件数>
const base = process.env.BASE_URL ?? 'http://127.0.0.1:3000';
const n = Number(process.argv[2] ?? 2);
const t0 = performance.now();
const sec = (ms) => (ms / 1000).toFixed(2);
const jobs = Array.from({ length: n }, () =>
fetch(`${base}/heavy`).then((r) => r.text()).then(() => performance.now() - t0),
);
let running = true;
const health = [];
const probe = (async () => {
while (running) {
const s = performance.now();
await fetch(`${base}/health`).then((r) => r.text());
health.push(performance.now() - s);
await new Promise((r) => setTimeout(r, 200));
}
})();
const finished = await Promise.all(jobs);
running = false;
await probe;
health.sort((a, b) => a - b);
console.log(`完了(秒): ${finished.map(sec).join(', ')}`);
console.log(`/health p50 ${sec(health[health.length >> 1])}s / max ${sec(health.at(-1))}s`);
サーバーを MODE=inline、HEAVY_MAX_WORKERS=1、=2 で起動し、node measure.mjs 2 を流した結果(10コアのMac)。
| メインスレッドで計算 | worker 1本 | worker 2本 | |
|---|---|---|---|
| 3秒の計算2件の完了 | 3.0秒 / 6.0秒 | 3.0秒 / 6.0秒 | 3.0秒 / 3.0秒 |
| /health の最大 | 6.0秒 | 0.01秒 | 0.01秒 |
コアが余っているので2本で並列になった。
自社環境での一例。2 vCPU / 1GiB のコンテナで、wasm のソルバで月のシフト表を組む処理を同時に2件、3回流した。
| worker 1本 | worker 2本 | |
|---|---|---|
| 同時2件の完了(最大) | 13.2秒(直列) | 7.75秒(並列) |
| ヘルスチェックの p50 | 0.145秒 | 0.153秒 |
| ヘルスチェックの最大 | 0.334秒 | 0.474秒 |
| メモリ(p99) | 1GiB の52% | 1GiB の約59%(グラフからの読み取り) |
ヘルスチェックの最大は伸びたが、サンプルが18〜33件と少なく、ばらつきの範囲かもしれない。p50 は変わらず完了が約1.7倍速いので、2本に決めた。3件以上が重なる場合は測っていない。
起動時に1回空で解いておく処理(ウォームアップ)が最初の worker でしか走らない実装だったため、2本目の worker は初回だけ約2秒遅かった。この初回(9.5〜9.8秒)は表に含めていない。
導入しても取りこぼすタイミングと経路
ソルバのように、計算に時間の上限(以下、締め切り)を入力で渡す処理では、表の2・3行目も当てはまる。
| タイミング・経路 | 何が起きるか | 対処 |
|---|---|---|
| 計算の前後(入力の組み立て・結果の変換・コピー) | メインスレッドに残り、その間は塞ぐ | 組み立てごと worker に出すか、前後で setImmediate を挟む |
| 待ち行列(締め切りを渡す場合) | 受付から数えると、待った分だけ計算の時間が減る | 締め切りは worker を借りた時点から数える |
| 複数段の計算(締め切りを渡す場合) | 段ごとに並べると別の依頼と交互になり、両方が締め切りを超える | 借りる・返すを分けた口をプールに足し、1件の間は借り切る |
| 新しく起こした worker の初回 | モジュールや wasm の読み込みぶん遅い | 起動時に全 worker で1回空の計算を流すか、初回の遅れを見込む |
| プロセス全体のメモリ不足 | worker ごとAPIが落ちる | 本数をメモリから決める。足りなければ子プロセスか別サービスへ |
| 複数インスタンス | 本数はインスタンスごとに効く | 台数×本数で、DBなど下流の上限と比べる |
AIエージェントに任せるときの指示
「重い処理を worker に逃がして」だけでもそれらしい実装は返るが、1件ごとの起動、締め切りの起点、失敗した worker の扱いが抜けやすい。どれもヘルスチェックのテストは通る。
<重い処理の関数> がメインスレッドで同期に走り、その間ほかのリクエストが止まります。
worker_threads のプールに移してください。
- worker はプールで使い回す。1 件ごとに new Worker しない
- 本数は環境変数 HEAVY_MAX_WORKERS で渡す。未設定なら 1、正の整数以外は起動時にエラー
- 上限を超えた依頼は FIFO で待たせる。計算に時間の上限を渡す場合は、worker を借りた時点から数える
- worker 内の例外・異常終了は 500 にしてログに残す。その worker は捨て、次は新しい worker で動かす
- 待機中の worker は unref する
- 入力の組み立てや結果の変換など、メインスレッドに残る処理を列挙して報告する
テストで次を確かめてください。
- 重い処理の最中に送ったヘルスチェックが、処理の完了を待たずに返る(実際の HTTP で)
- worker 内で例外が起きた依頼は 500、次の依頼は 200
- 本数 2 のとき、2 件が別々の worker で同時に走る
- 上のヘルスチェックのテストは、プールをメインスレッドで計算する版に差し替えると
落ちることも確かめる
最後の項目は、worker 化を外しても通ってしまうテストを弾くためだ。本数の計測は本番と同じ構成が要るので人が回し、エージェントにはスクリプトを用意させる。
別案:cluster でプロセスを増やせば済むのでは
cluster で子プロセスを増やせば止まらなくなりそうだが、実際は重い計算を受けたプロセスが塞がり、そこへ振られたリクエストは待たされる。止まる範囲が狭くなるだけだ。
まとめ
同期の計算はメインスレッドを手放さない。HTTPの応答で返せる長さなら worker_threads のプール、ネイティブコードが落ちたり止まったりしうるなら子プロセス、数十秒を超えるなら別サービスに置く。
worker の本数はvCPUとメモリから決め、本番と同じ構成で測ってから上げる。置き場所は速さより「落ちたとき、詰まったときに何が一緒に止まるか」で決めると外しにくい。



