Worker Threads
Node.js で CPU 集約的な処理をメインスレッドをブロックせずに別スレッドで実行する仕組み
Worker Threads とは
Worker Threads は、Node.js で CPU 集約的な処理をメインスレッドとは別のスレッドで実行する仕組みである。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 を回し続ければそのワーカーは応答しなくなる。ブロックの問題が消えるのではなく、ブロックしてよい場所をメインスレッドの外へ移すのが本質である。
なお、fs や crypto.pbkdf2() の非同期版が使う libuv のスレッドプールは、これとは別の仕組みである。そちらはランタイムが内部で管理するプールで、JavaScript のコードが乗ることはない。
Worker Threads vs Child Process
| 観点 | Worker Threads | Child 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 で十分に効率的に処理できる。ワーカーへ逃がしても、待つ場所がメインスレッドからワーカーへ移るだけで、待ち時間そのものは短くならない。
判断の順序としては、まず処理がイベントループを実際にどれだけ止めているかを測るのが先である。ブロック時間が数ミリ秒に収まるなら、受け渡しのコピーコストとプールの複雑さのほうが割高になる。
この記事は役に立ちましたか?
関連用語
イベントループ
Node.js のシングルスレッドで非同期 I/O を実現する実行モデル
プロセスとスレッドの違いとは - メモリ空間 / 生成コスト / 通信方法を比較
プロセスとスレッドの違いをメモリ空間 / 生成コスト / 通信方法の 3 軸で比較。Node.js での使い分け、Lambda の実行環境、データ競合とコンテナの PID 1 の落とし穴まで解説
並行処理 (Concurrency) とは - 並列処理 (Parallelism) との違い
Concurrency (並行処理) の意味を解説。複数のタスクを論理的に同時進行させる手法で、物理的に同時実行する並列処理 (Parallelism) とは区別される。Node.js / Go / Rust の並行処理モデルの違いも紹介。
スレッドプール
事前に生成したスレッドを再利用し、スレッド生成のオーバーヘッドを削減する並行処理パターン
goroutine
Go の軽量スレッドで、数十万規模の並行処理を低コストで実現する
Node.js
Chrome V8 エンジン上で動作するサーバーサイド JavaScript ランタイム
関連する記事
技術書の知識を定着させる間隔反復法 - 読んだのに忘れる問題を解決する
技術書を読んでも内容を忘れてしまう原因を認知科学の観点から分析し、間隔反復法を使って知識を長期記憶に定着させる具体的な方法を紹介します。
技術書の読む順番戦略 - 複数冊を組み合わせて理解を加速させる
技術書を 1 冊ずつ読むのではなく、複数冊を戦略的に組み合わせることで、1 冊では届かない理解の深さと広さに達する方法を解説します。
Linux 本ガイド - コマンドライン / しくみ / 性能の 3 層で選ぶ技術書
Linux を学ぶ技術書の選び方を 3 層 (コマンドラインの操作 → カーネルのしくみ → 性能と運用) で整理。新しい Linux の教科書や [試して理解] Linux のしくみなどの定番書の使い分けと、学ぶ順番を解説します。