Node.js 22のワーカースレッドで実現する並列処理の完全ガイド2025
8コア以上の汎用VMが標準化した現在、シングルスレッドのままではCPUの半分以上を遊ばせているという現実は、多くのNode.jsバックエンドで見過ごされがちです。一般に、CPUバウンド(計算集中型)の処理はイベントループ上で直列実行するよりも、ワーカースレッドで並列化することでスループットの向上と高パーセンタイル(P95など)のレイテンシ改善が見込めます。I/O中心では恩恵が限定的ですが、暗号、画像処理、数値計算、圧縮のような計算集中型のユースケースでは、プロセス分割ではなく同一プロセス内のワーカースレッドへオフロードする設計がメモリ効率と運用容易性の両面で有力な選択肢になります[1][2]。
Node.js 22はV8の更新とランタイム改善により、ワーカー生成・メッセージングの実用性が十分に成熟しています。ワーカー生成には一定のオーバーヘッド(一般に数十ミリ秒オーダー)を伴うため、突発的に短命のワーカーを乱立させるのではなく、ワーカープールで再利用する設計が鍵になります[3]。データの受け渡しでは、数MB以上のバイナリは構造化複製ではなくTransferableなArrayBufferへ切り替えることで、コピーを避けレイテンシとGC負荷を抑制できます[5][6]。共有メモリを介したプログレス通知やキャンセルは、Atomic操作と合わせて実装すると堅牢です[7]。この記事では、CTOやTech Leadが意思決定できるレベルで、選定基準、実装パターン、同期原語、観測性・運用、ベンチマークの取り方とROIの考え方までを、公開ドキュメントとコード例をもとに整理します[4]。
Node.js 22とワーカースレッドの現在地と選定基準
ワーカースレッドは同一プロセス内に複数のJavaScript実行スレッドを持ち、メインスレッドのイベントループから切り離してCPU集中タスクを実行します[1]。child_processやclusterと異なり、プロセス境界が無い一方でヒープを共有しないため、メモリ消費やコンテキスト切り替えのコストを抑えつつ、構造化複製やTransferable、SharedArrayBufferを活用した効率的なデータ受け渡しが可能です[2][5][6][7]。I/O中心のAPIサーバでワーカーを闇雲に増やすのは逆効果になり得ますが、リクエストの一部にCPUバウンドが混在する構成では、CPU集中タスクのみをワーカーへオフロードすることで、イベントループのレイテンシ悪化を避けられます[1]。
Node.js 22の実務上の利点は、安定したworker_threads API、広範なクラウドランタイムでの互換性、そして既存監視基盤との親和性にあります。ワーカー生成はコストがかかるため、突発的に短命のワーカーを増殖させるのではなく、ワーカープールで再利用する設計が推奨されます[3]。データの受け渡しでは、数MB以上のバイナリは構造化複製ではなくTransferableなArrayBufferへ切り替えることで、コピーを避ける設計が効果的です[5][6]。共有メモリを介したプログレス通知やキャンセルは、Atomic操作と合わせて実装すると堅牢です[7]。
実装パターンとベストプラクティス(コード6選)
基本形:CPUタスクのオフロードと型安全なメッセージ
まず最小構成で、メインスレッドからCPU集中タスクをワーカーへ送る実装です。ESMで統一し、データ型を明確にします。
// package.json: { "type": "module" }
// main.mjs
import { Worker } from 'node:worker_threads';
function runTask(payload) {
return new Promise((resolve, reject) => {
const w = new Worker(new URL('./worker.mjs', import.meta.url), { workerData: payload });
w.once('message', resolve);
w.once('error', reject);
w.once('exit', (code) => { if (code !== 0) reject(new Error('Worker exit ' + code)); });
});
}
const input = { n: 45 }; // CPU重いフィボナッチ
const t0 = performance.now();
const result = await runTask(input);
console.log('result', result, 'ms', Math.round(performance.now() - t0));
// worker.mjs
import { parentPort, workerData } from 'node:worker_threads';
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
const out = { value: fib(workerData.n) };
parentPort.postMessage(out);
短命ワーカーはシンプルですが、繰り返し実行するなら次のようにプール化した方が安定して高スループットを維持できます[3]。
プール化:再利用可能なワーカーとキュー
このプールはシンプルなラウンドロビンで、バースト時にも過剰生成を避けられます。可読性を優先し、スループット重視でbackpressureを自然にかけます。
// pool.mjs
import { Worker } from 'node:worker_threads';
export class WorkerPool {
constructor(size, script) {
this.size = size;
this.script = script;
this.idle = [];
this.queue = [];
for (let i = 0; i < size; i++) this.idle.push(this._create());
}
_create() { return new Worker(this.script, { type: 'module' }); }
exec(job) {
return new Promise((resolve, reject) => {
const run = (w) => {
const onMessage = (msg) => { cleanup(); resolve(msg); this.idle.push(w); this._drain(); };
const onError = (err) => { cleanup(); reject(err); };
const onExit = (code) => { cleanup(); if (code !== 0) reject(new Error('exit ' + code)); };
const cleanup = () => { w.off('message', onMessage); w.off('error', onError); w.off('exit', onExit); };
w.once('message', onMessage);
w.once('error', onError);
w.once('exit', onExit);
w.postMessage(job);
};
this.queue.push({ run, resolve, reject });
this._drain();
});
}
_drain() {
while (this.idle.length > 0 && this.queue.length > 0) {
const w = this.idle.pop();
const task = this.queue.shift();
task.run(w);
}
}
}
// usage.mjs
import os from 'node:os';
import { WorkerPool } from './pool.mjs';
const pool = new WorkerPool(Math.min(8, os.cpus().length), new URL('./worker-loop.mjs', import.meta.url));
const jobs = Array.from({ length: 32 }, (_, i) => ({ n: 40 + (i % 3) }));
const t0 = performance.now();
const results = await Promise.all(jobs.map(j => pool.exec(j)));
console.log('done', results.length, 'ms', Math.round(performance.now() - t0));
// worker-loop.mjs
import { parentPort } from 'node:worker_threads';
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
parentPort.on('message', (job) => {
parentPort.postMessage({ ok: true, value: fib(job.n) });
});
これによりワーカー生成のオーバーヘッドを一度に集約でき、レイテンシの分散が安定します。プールサイズはコア数とヒートスロットリングを見ながら決め、CPU-boundの場合はコア数と同数、I/O待ちを含む場合はやや少なめが扱いやすくなります。
エラーハンドリングとキャンセル:AbortController連携
長時間タスクはキャンセル可能性とタイムアウトが重要です。親側でAbortSignalを管理し、ワーカーへ通知して早期終了します。
// cancel.mjs
import { Worker } from 'node:worker_threads';
export function execWithTimeout(script, job, ms) {
const ac = new AbortController();
const w = new Worker(script, { type: 'module' });
const timer = setTimeout(() => ac.abort(new Error('timeout')), ms);
return new Promise((resolve, reject) => {
const cleanup = () => { clearTimeout(timer); w.terminate(); };
w.on('message', (msg) => { cleanup(); resolve(msg); });
w.on('error', (err) => { cleanup(); reject(err); });
w.on('exit', (code) => { if (code !== 0) { cleanup(); reject(new Error('exit ' + code)); } });
w.postMessage({ job, cancel: ac.signal.aborted });
ac.signal.addEventListener('abort', () => { w.postMessage({ cancel: true }); });
});
}
// worker-cancel.mjs
import { parentPort } from 'node:worker_threads';
function heavy(n, shouldCancel) {
let a = 0, b = 1;
for (let i = 0; i < n; i++) {
if (shouldCancel()) return null;
const t = a + b; a = b; b = t;
}
return a;
}
let cancelled = false;
parentPort.on('message', ({ job, cancel }) => {
if (cancel) { cancelled = true; return; }
const out = heavy(job.n, () => cancelled);
parentPort.postMessage({ cancelled, value: out });
});
ワーカーの早期終了はterminateで強制できますが、計算の中断ポイントを用意して協調的に抜ける方がメモリと一貫性の観点で安全です。
大きなデータの受け渡し:Transferableでコピー回避
数十MBのバッファを構造化複製するとGCとコピーで大きなペナルティを負います。ArrayBufferをTransferableとして送れば、オーナーシップを移すだけでコピーを避けられます[5][6]。
// transfer.mjs
import { Worker } from 'node:worker_threads';
const w = new Worker(new URL('./worker-transfer.mjs', import.meta.url), { type: 'module' });
const buf = new Uint8Array(32 * 1024 * 1024); // 32MB
crypto.getRandomValues(buf);
w.postMessage({ buf }, [buf.buffer]); // Transfer
w.once('message', (msg) => {
console.log('checksum', msg.sum);
});
// worker-transfer.mjs
import { parentPort } from 'node:worker_threads';
parentPort.on('message', ({ buf }) => {
const u8 = new Uint8Array(buf); // 所有権はワーカー側へ
let sum = 0; for (let i = 0; i < u8.length; i++) sum = (sum + u8[i]) | 0;
parentPort.postMessage({ sum });
});
この方式では親側のバッファはdetachされるためアクセス不能になります。必要ならSharedArrayBufferへ切り替えます[7]。
共有メモリと同期:SharedArrayBufferとAtomicsの実践
共有メモリはプログレス共有やキャンセル、軽量なキューに有効です。Atomic操作でデータ競合を避けながら、ポーリングではなく待機ベースの同期を構成します[7]。
// shared.mjs
import { Worker } from 'node:worker_threads';
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 4);
const view = new Int32Array(sab); // [progress, total, cancel, ready]
view[1] = 100000000; // total
const w = new Worker(new URL('./worker-shared.mjs', import.meta.url), { workerData: sab, type: 'module' });
const t0 = performance.now();
const tick = setInterval(() => {
const p = Atomics.load(view, 0);
const total = Atomics.load(view, 1);
console.log('progress', Math.floor((p / total) * 100), '%');
if (p > total * 0.6) Atomics.store(view, 2, 1); // cancel after 60%
}, 200);
w.on('message', (msg) => {
clearInterval(tick);
console.log('done', msg, 'ms', Math.round(performance.now() - t0));
});
// worker-shared.mjs
import { workerData, parentPort } from 'node:worker_threads';
const view = new Int32Array(workerData);
function work(total) {
let acc = 0;
for (let i = 0; i < total; i++) {
acc = (acc + (i % 97)) | 0;
if ((i & 0x3fff) === 0) {
Atomics.store(view, 0, i);
if (Atomics.load(view, 2) === 1) return { cancelled: true, acc };
}
}
Atomics.store(view, 0, total);
return { cancelled: false, acc };
}
const total = Atomics.load(view, 1);
parentPort.postMessage(work(total));
ポーリング頻度はCPU消費とプログレス精度のトレードオフです。数万イテレーションに一度のAtomic操作でもプログレス表示の滑らかさは実用上十分で、過度に短い間隔での更新を避ければCPUオーバーヘッドを抑制できます。キャンセルは共有フラグの読み取りで協調的に行います。
観測性・運用・ベンチマークとROI
観測性:メトリクス、トレース、ヒープ圧の把握
プールに投入するジョブは、スループット、キュー滞留時間、ジョブ実行時間、ワーカー数の有効活用率、メモリ使用量を継続的に記録します。AsyncLocalStorageはスレッドをまたがって自動伝搬しないため、トレースIDはメッセージに明示的に含め、ワーカー側でログに埋め込みます。perf_hooksのPerformanceObserverで区間計測を入れ、障害時はメインスレッドのログと突き合わせる運用が有効です[8]。
// tracing.mjs
import { Worker } from 'node:worker_threads';
import { performance, PerformanceObserver } from 'node:perf_hooks';
const obs = new PerformanceObserver((items) => {
for (const e of items.getEntries()) console.log('metric', e.name, e.duration);
});
obs.observe({ entryTypes: ['measure'] });
function execWithTrace(pool, job) {
const id = crypto.randomUUID();
performance.mark(id + ':start');
return pool.exec({ ...job, traceId: id }).finally(() => {
performance.mark(id + ':end');
performance.measure('job:' + job.kind, id + ':start', id + ':end');
});
}
ベンチマーク:単一スレッド対ワーカープール
以下は同一アルゴリズムを直列と並列で実行し、スループットとP95を比較する最小ベンチです。環境はNode.js 22系、8 vCPU、32GB RAMなどの一般的なサーバを想定しています。実際の結果はアルゴリズム特性と入出力分布、CPUクォータ、プールサイズに大きく依存します。
// bench.mjs
import os from 'node:os';
import { Worker } from 'node:worker_threads';
import { performance } from 'node:perf_hooks';
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
async function serial(jobs) {
const t0 = performance.now();
const res = jobs.map(j => fib(j.n));
return { ms: performance.now() - t0, res };
}
function parallel(jobs, size) {
return new Promise((resolve) => {
const t0 = performance.now();
const w = Array.from({ length: size }, () => new Worker(new URL('./worker-loop.mjs', import.meta.url), { type: 'module' }));
let i = 0, done = 0;
const out = new Array(jobs.length);
for (const worker of w) {
const run = () => {
if (i >= jobs.length) return;
const idx = i++;
worker.once('message', (msg) => { out[idx] = msg.value; done++; if (done === jobs.length) resolve({ ms: performance.now() - t0, res: out }); else run(); });
worker.postMessage(jobs[idx]);
};
run();
}
});
}
const jobs = Array.from({ length: 24 }, () => ({ n: 42 }));
const serialRes = await serial(jobs);
const parallelRes = await parallel(jobs, Math.min(8, os.cpus().length));
console.log({ serialMs: Math.round(serialRes.ms), parallelMs: Math.round(parallelRes.ms), speedup: (serialRes.ms / parallelRes.ms).toFixed(2) + 'x' });
観測されるスピードアップは、概ね物理コア数を上限とする伸びが目安になります(計算が純粋にCPUバウンドである場合)。一方で、ワーカー間の偏りやジョブ長のばらつきが大きいとP95/P99が悪化しやすく、重み付きスケジューリングやワークスティーリングを導入すると改善が見込めます。最終的な判断は、ここで示したスクリプトのように自前のワークロードで計測して得られた数値を基準にしてください。
ROI:インフラと開発コストのバランス
CPUバウンドがボトルネックのサービスでは、プロセス分割よりもワーカースレッドの導入で、同一Pod内のスループットを高めつつメモリ常駐量を抑えられる場合があります[2]。たとえば画像変換を含むAPIで並列化の適用範囲が広がれば、レプリカ数やインスタンスタイプの見直し余地が生まれ、計算資源コストの削減につながります。開発面ではプール、転送、共有メモリ、計測のユーティリティを一度整備すれば、以降のCPUタスク追加はメッセージプロトコルとワーカー関数の実装に集約され、機能追加のリードタイム短縮に寄与します。障害時の切り分けもプロセス境界を跨がないため観測コストが低く、運用の摩擦が小さいのも利点です。
セキュリティとサンドボックスの補足
ワーカーは同一プロセスのため、モジュール解決や環境変数へのアクセス権は基本的に共有されます[2]. サードパーティコード実行のユースケースでは、実行ポリシーの分離が必要ならプロセスアイソレーションを選び、同一信頼境界での高速化が主目的ならワーカーを選ぶ判断が現実的です。ファイルI/Oやネットワークアクセスの制御が要件になる場合は、許可リスト方式でワーカー側のモジュール読み込みとメッセージプロトコルを制限し、入力検証を徹底します。
実務Tips:デプロイとスケーリング
コンテナのCPUリミットを厳しめに設定するとスレッドがスロットルされ、期待したスピードアップを得られないケースがあります。要求するCPUクォータに対してプールサイズを素直に合わせ、オートスケーリングの指標はキュー滞留時間と実行中ジョブ数を併用します。グレースフルシャットダウンでは新規ジョブ受け付けを止め、進行中ジョブの完了かタイムアウトでワーカーを段階的に停止します。
まとめ:2025年の標準実装としての設計指針
Node.js 22のワーカースレッドは、CPUバウンドを含むバックエンドの現実解として十分に成熟しました。イベントループを詰まらせずにスループットとP95を同時に押し上げるには、プールによる再利用、Transferable/SharedArrayBufferの適切な使い分け、協調的キャンセル、計測に基づくプールサイズ調整という四点を軸にした設計が効果的です。コード例を足がかりに、まずは一つのCPU集中ルートを切り出して並列化し、計測でボトルネックを確認しながら段階的に適用範囲を広げてください。
ワーカースレッドは魔法ではありませんが、正しい対象に、正しい粒度で適用すれば、費用対効果は明確にプラスになります。次のスプリントで一箇所だけでもワーカー化し、ベースラインと比較する小さな実験から始めてみませんか。計測結果が意思決定を後押しし、2025年のバックエンド標準実装への移行に確かな根拠を与えてくれるはずです。
参考文献
- Node.js Documentation — worker_threads: Workers are useful for performing CPU-intensive JavaScript operations; they do not help much with I/O-intensive work. https://nodejs.org/download/release/v22.12.0/docs/api/worker_threads.html
- Node.js Documentation — worker_threads: Unlike child_process or cluster, workers can share memory. https://nodejs.org/download/release/v22.12.0/docs/api/worker_threads.html
- Node.js Documentation — worker_threads: In real-world applications, use a pool of Workers to avoid overhead that would likely exceed their benefit. https://nodejs.org/download/release/v22.12.0/docs/api/worker_threads.html
- Node.js v22 Release Announcement: V8 Maglev enabled by default and performance improvements. https://nodejs.org/en/blog/announcements/v22-release-announce/
- Node.js Documentation — worker_threads: Message passing uses the structured clone algorithm and supports transfer lists (ArrayBuffer). https://nodejs.org/download/release/v22.12.0/docs/api/worker_threads.html
- MDN Web Docs — Transferable objects(日本語): 転送によりコピーを避け、元のバッファは使用不能になる。https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Transferable_objects
- MDN Web Docs — SharedArrayBuffer: 共有メモリとAtomicsによる同期の必要性。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer
- Node.js Documentation — perf_hooks: PerformanceObserver での計測。https://nodejs.org/download/release/v22.12.0/docs/api/perf_hooks.html