シングルスレッドの罠:なぜWorker Threadsが必要なのか
Node.jsは、数千ものデータベースクエリを同時に処理するなど、I/Oバウンドなタスクにおいて非常に効率的であることで知られています。しかし、そのシングルスレッドのイベントループアーキテクチャには、CPU負荷の高い処理という大きな弱点があります。同期的に500ミリ秒かかるタスクを実行すると、サーバー全体が他のすべてのユーザーに対して0.5秒間応答を停止してしまいます。高トラフィックな環境では、これによりp99レイテンシが急上昇し、連鎖的な障害を引き起こす可能性があります。
イベントループがブロックされると、すべてが停止します。新しいTCP接続は無視され、スケジュールされた setTimeout のコールバックは遅延します。最近、50MBのファイルのJSONパース処理がスレッドを1.2秒間ブロックしたためにヘルスチェックが失敗し、不要なコンテナの再起動が引き起こされた本番環境の問題を調査しました。Worker Threadsをマスターすることは、単なるパフォーマンスの向上ではなく、システムの安定性のための必須要件です。
worker_threads モジュールが登場する前、開発者は cluster や child_process を使用していました。これらも機能しますが、オーバーヘッドが大きいです。各子プロセスは独自のメモリインスタンスを必要とし、起動するだけで30MB以上のメモリを消費することも珍しくありません。Node.js 10.5.0で導入されたWorker Threadsは、同じプロセス内で複数のJavaScript環境を実行することで、効率的なメモリ共有を可能にし、この問題を解決します。
環境のセットアップ
worker_threads モジュールはNode.js의 コアに組み込まれています。バージョン10から利用可能ですが、起動時間の改善やESMサポートの向上を享受するために、Node.js v18またはv20以降の使用を推奨します。以下のコマンドで環境を確認してください:
node -v
生のワーカーは強力ですが、本番環境で手動で管理するのはリスクが伴います。リクエストごとに新しいスレッドを作成すると、約10〜15ミリ秒のオーバーヘッドが発生し、約20MBのRAMを消費します。これを軽減するには、piscina のようなスレッドプールライブラリを使用します。これはタスクのキューを管理し、ワーカーを待機状態(ウォーム状態)に保つため、動的に生成するよりもはるかに効率的です。
npm install piscina
大規模環境向けのワーカー構成
効果的なスレッド管理には、関心の分離が不可欠です。ワーカーは「計算」という一つのことに特化したスクリプトであるべきです。
1. ワーカースクリプト
processor-worker.js を作成します。このスクリプトはデータを受け取り、重い処理を実行して、結果を返します。
const { parentPort, workerData } = require('worker_threads');
// 実例:重いデータ変換やBcryptのハッシュ化
function processData(data) {
let count = 0;
for (let i = 0; i < data.limit; i++) {
count += Math.sqrt(i);
}
return count;
}
const result = processData(workerData);
parentPort.postMessage(result);
2. メインスレッドとの統合
メインアプリケーションはワーカーのライフサイクルを管理する必要があります。ワーカーをPromiseでラップすることで、現代的な async/await パターンと互換性を持たせ、コードベースをクリーンに保つことができます。
const { Worker } = require('worker_threads');
function spawnWorker(data) {
return new Promise((resolve, reject) => {
const worker = new Worker('./processor-worker.js', { workerData: data });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`ワーカーが終了コード ${code} で失敗しました`));
});
});
}
async function handleHeavyRequest(req, res) {
try {
// 1億回のループ処理をバックグラウンドスレッドにオフロードする
const result = await spawnWorker({ limit: 100000000 });
res.send({ status: '完了', result });
} catch (err) {
res.status(500).send({ error: '処理に失敗しました' });
}
}
3. SharedArrayBufferによる最適化
スレッド間で大きなオブジェクトを渡す場合、通常はデータをコピーするStructured Cloneアルゴリズムが使用されます。10MBのバッファの場合、このコピー操作に数ミリ秒かかることがあります。SharedArrayBuffer を使用すると、スレッドが同じ物理メモリをマップできるため、クローニングのオーバーヘッドを完全に排除できます。大きな画像バッファや分析データセットを処理する場合に使用してください。
// メインスレッドで1MBの共有メモリを割り当てる
const sharedBuffer = new SharedArrayBuffer(1024 * 1024);
const worker = new Worker('./worker.js', { workerData: { buffer: sharedBuffer } });
本番環境でのモニタリングとガードレール
実装は戦いの半分に過ぎません。スレッドに負荷がかかっているときの挙動を監視し、システムリソースを枯渇させないようにする必要があります。
1. 遅延(ラグ)の測定
イベントループの遅延を測定することで、メインスレッドの状態を追跡します。ワーカーが正しく構成されていれば、負荷が高いときでもこの遅延は10〜20ミリ秒未満に保たれるはずです。ネイティブの perf_hooks モジュールを使用して、これらのメトリクスを取得します:
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay();
histogram.enable();
setInterval(() => {
console.log(`p99 イベントループ遅延: ${histogram.percentile(99) / 1e6}ms`);
}, 5000);
2. タイムアウトの実装
ワーカーもメインスレッドと同様にハングしたり無限ループに陥ったりする可能性があります。ワーカーを無期限に実行させてはいけません。通常200ミリ秒で終わるタスクが2秒経っても終わらない場合は、何かが間違っています。worker.terminate() を呼び出してスレッドを強制終了し、CPUコアを解放してください。このフェイルセーフにより、一つのバグのあるタスクがサーバー全体のパフォーマンスを低下させるのを防ぐことができます。
3. コア数との整合性
CPUのキャパシティを超えないようにしてください。サーバーに4つのコアがある場合、10個のワーカーを同時に実行するとコンテキストスイッチのオーバーヘッドが発生し、実際には処理が遅くなります。目安としては、スレッドプールのサイズを os.cpus().length - 1 に設定し、1つのコアをメインのイベントループとI/Oタスク専用に残しておくのが良いでしょう。
Worker Threadsは外科手術用のツールのようなものです。複雑な計算、画像のリサイズ、複雑なPDF生成などに使用してください。 標準的なAPIロジックには、Node.jsのデフォルトの非ブロックパターンを使用し続けてください。コードの重い5%だけをワーカーにオフロードすることで、基盤となるハードウェアを変更することなく、スループットを10倍向上させることができます。

