Worker Threads

Node.js で CPU 集約的な処理をメインスレッドをブロックせずに別スレッドで実行する仕組み

Node.js並行処理

Worker Threads とは

Worker Threads は、Node.jsCPU 集約的な処理をメインスレッドとは別のスレッドで実行する仕組みである。v10.5.0 で --experimental-worker フラグ付きの実験的機能として入り、v11.7.0 でフラグが不要になり、v12.11.0 で安定版になった。

Node.js はシングルスレッドのイベントループで I/O を効率的に処理するが、CPU を長時間占有する処理 (画像処理、暗号化、大量データの変換) はイベントループをブロックし、他のリクエストが処理できなくなる。Worker Threads はこの問題を解決する。

ここで重要なのは、ワーカーが「メインスレッドの続き」ではないことだ。ワーカーは自分専用の V8 インスタンスとイベントループを持つ独立した実行環境で、変数もモジュールのキャッシュも一切共有しない。同じプロセス内に居ながら、メモリ上は別世界として振る舞う。

基本的な使い方

new Worker() にワーカー側スクリプトのパスを渡すと、そのスクリプトが別スレッドで先頭から実行される。結果は postMessage でメインスレッドへ返す。

// main.mjs (メインスレッド)
import { Worker } from 'node:worker_threads';

function runWorker(nums) {
  return new Promise((resolve, reject) => {
    // 相対パス文字列は「実行時のカレントディレクトリ基準」で解決される。
    // import.meta.url 基準の URL にしておけば、どこから起動しても壊れない
    const worker = new Worker(new URL('./worker.mjs', import.meta.url), {
      workerData: nums,
    });
    worker.once('message', resolve);
    worker.once('error', reject);
    // message が来ないまま終了した場合を取りこぼさない
    worker.once('exit', (code) => {
      if (code !== 0) reject(new Error(`worker exited with ${code}`));
    });
  });
}

const result = await runWorker([1, 2, 3, 4, 5]);
console.log('Sum:', result);
// worker.mjs (ワーカースレッド)
import { parentPort, workerData } from 'node:worker_threads';

const sum = workerData.reduce((a, b) => a + b, 0);
parentPort.postMessage(sum);

exit の待ち受けを省くと、ワーカーが例外で落ちる前に error を出さずに終了した場合に Promise が永久に解決されない。実装で最初に踏むのはここである。

データの渡し方は 3 通りある

「同一プロセスだからメモリを共有できる」と読むと設計を誤る。既定の受け渡しはコピーであり、共有するには明示的な指定が要る。

方法実際に起きること向く場面
postMessage(value) / workerData構造化複製アルゴリズム複製される。サイズに比例したコストがかかる小さな引数・結果の受け渡し
postMessage(value, [buffer])ArrayBuffer の所有権を移譲する。コピーは発生しないが、渡した側のバッファは切り離されて byteLength が 0 になる大きなバイナリを片方向に引き渡す
SharedArrayBuffer両スレッドが同じメモリを同時に読み書きする。コピーも移譲もない双方が並行して触る数値データ

構造化複製に乗らない値もある。関数、クラスのメソッド、WeakMap は渡せず、クラスインスタンスを渡してもプロトタイプは失われてプレーンなオブジェクトになる。渡す値は素のデータに寄せておくのが安全だ。

SharedArrayBuffer は真の共有メモリなので、複数スレッドが同じ要素を更新すると競合が起きる。カウンタの加算や進捗フラグのように衝突しうる更新は Atomics.add()Atomics.store() を使い、待ち合わせは Atomics.wait() / Atomics.notify() で行う。共有できるのは数値のバッファだけで、任意のオブジェクトを共有する手段は用意されていない。

イベントループとの関係

メインスレッド (イベントループ)
├── HTTP リクエストの受信・レスポンス送信
├── I/O 操作のコールバック処理
└── Worker Thread への処理委譲

Worker Thread 1: 画像リサイズ処理   (専用のイベントループ)
Worker Thread 2: PDF 生成処理       (専用のイベントループ)
Worker Thread 3: データ変換処理     (専用のイベントループ)

メインスレッドは I/O とリクエストのルーティングに専念し、CPU 集約的な処理は Worker Thread に委譲する。ワーカー側もイベントループを持つため、ワーカー内で同期的に CPU を回し続ければそのワーカーは応答しなくなる。ブロックの問題が消えるのではなく、ブロックしてよい場所をメインスレッドの外へ移すのが本質である。

なお、fscrypto.pbkdf2() の非同期版が使う libuv のスレッドプールは、これとは別の仕組みである。そちらはランタイムが内部で管理するプールで、JavaScript のコードが乗ることはない。

Worker Threads vs Child Process

観点Worker ThreadsChild Process (fork)
メモリ既定はコピー。SharedArrayBuffer で共有、transferList で移譲もできる独立 (プロセス間通信が必要)
起動コストプロセス生成より軽いが無料ではない (V8 インスタンスとイベントループを新規に作る)高い (プロセス生成)
分離レベル同一プロセス内 (片方が落ちるとプロセス全体に影響しうる)完全に独立
適するケースCPU 集約的な計算外部コマンドの実行、クラッシュ分離

分離レベルの差は障害時に効く。ワーカー内でメモリを使い切れば同じプロセスのメインスレッドも一緒に落ちるため、信頼できないコードや落ちる前提のコードを走らせるなら Child Process を選ぶ。

生成のたびに作らずプールにする

起動コストが低いのは Child Process との比較の上での話で、リクエストごとに new Worker() するとその立ち上げ時間が応答時間に丸ごと乗る。スレッド数を CPU コア数程度に固定したプールを用意し、タスクだけを投入する形にするのが定石である。自分でキューと空きスレッドの管理を書かずに済ませるなら piscina のようなプール実装が使える。

プールを作るとサイズが上限として効く。CPU バウンドなタスクではコア数を超えて増やしても実行待ちが伸びるだけで、スループットは改善しない。

使うべきケースと使うべきでないケース

使うべき使うべきでない
画像・動画の処理DB クエリ (I/O は非同期で十分)
暗号化・ハッシュ計算HTTP リクエスト (I/O)
大量データの変換・集計ファイル読み書き (I/O)
PDF 生成単純な CRUD 処理

I/O バウンドな処理には Worker Threads は不要だ。Node.js の非同期 I/O で十分に効率的に処理できる。ワーカーへ逃がしても、待つ場所がメインスレッドからワーカーへ移るだけで、待ち時間そのものは短くならない。

判断の順序としては、まず処理がイベントループを実際にどれだけ止めているかを測るのが先である。ブロック時間が数ミリ秒に収まるなら、受け渡しのコピーコストとプールの複雑さのほうが割高になる。

この記事は役に立ちましたか?

関連用語

関連する記事