Worker Threads による Node.js スケーラビリティの解放
Ethan Miller
Product Engineer · Leapcell

はじめに
長年にわたり、開発者は Node.js のノンブロッキング I/O モデルとイベント駆動型アーキテクチャを高く評価し、スケーラビリティの高い Web サーバーやリアルタイム アプリケーションの構築に最適な選択肢としてきました。しかし、そのシングル スレッドとしての性質は、長年の課題でした。I/O バウンド操作(データベースのやり取りやネットワーク リクエストなど)には最適ですが、CPU バウンド タスク(複雑な計算、データ圧縮、画像処理など)はイベント ループをブロックし、パフォーマンスのボトルネックや応答性の低いアプリケーションにつながる可能性があります。この制限により、開発者はしばしば集中的なタスクを外部サービスにオフロードしたり、他の言語を検討したりする必要がありました。今日、Node.js で
worker_threads
が登場したことで、このシングル スレッドのボトルネックに終止符を打ち、単一の Node.js プロセス内で真の並列処理を解き放つことができるようになりました。この記事では
worker_threads
が Node.js アプリケーションに、CPU 集約型のワークロードをより効率的に処理する力を与え、よりスムーズな運用とスケーラビリティの向上を保証する方法を詳しく説明します。
Worker Threads によるシングル スレッド ボトルネックの克服
worker_threads
の重要性を理解するには、まず関連するコア コンセプトを把握する必要があります。
イベント ループ: Node.js の中心にはイベント ループがあり、すべての JavaScript 実行、コールバック、I/O 操作を処理する単一のスレッドです。CPU 集約型タスクがこのスレッドで実行されると、イベント ループを専有し、完了するまで他の操作が処理されないようにします。これは「イベント ループのブロック」として知られています。
スレッド: スレッドとは、スケジューラによって独立して管理できるプログラム命令の最小シーケンスです。従来、Node.js は主に単一のメイン スレッドで実行されていました。
worker_threads
は、同じ Node.js プロセス内に追加のスレッドを作成する機能をもたらします。
ワーカー スレッド: メイン スレッドとは異なり、ワーカー スレッドは独自の V8 インスタンスとイベント ループを備えた分離された環境で実行されます。この分離は、ワーカーで実行される CPU バウンド タスクがメイン スレッドのイベント ループをブロックしないため、重要です。それらはメッセージ パッシング メカニズムを介して互いに通信します。
動作原理
worker_threads
の背後にあるコア原則は、CPU 集約型タスクをメイン スレッドから別のワーカー スレッドにオフロードすることです。メイン スレッドが計算負荷の高い操作に遭遇すると、それを直接実行するのではなく、ワーカー スレッドを生成します。ワーカー スレッドは計算を実行し、完了すると、メッセージ経由で結果をメイン スレッドに返します。これにより、メイン スレッドは中断なしに他のリクエストを処理し続け、アプリケーションの応答性を維持できます。
実装の詳細と例
大規模な範囲内の素数を見つけるなどの複雑な CPU バウンド計算を実行する実践的な例でこれを説明しましょう。
まず、worker_threads
なしでブロック操作がどのように見えるかを見てみましょう。
// main.js - worker_threads なし (ブロック中!) function findPrimes(start, end) { const primes = []; for (let i = start; i <= end; i++) { let isPrime = true; if (i <= 1) { isPrime = false; } else { for (let j = 2; j <= Math.sqrt(i); j++) { if (i % j === 0) { isPrime = false; break; } } } if (isPrime) { primes.push(i); } } return primes; } const express = require('express'); const app = express(); const port = 3000; app.get('/blocking-prime', (req, res) => { console.log('Received blocking prime request'); const primes = findPrimes(2, 20_000_000); // これはイベント ループをブロックします res.json({ count: primes.length, firstPrime: primes[0], lastPrime: primes[primes.length - 1] }); console.log('Finished blocking prime request'); }); app.get('/non-blocking', (req, res) => { console.log('Received non-blocking request'); res.send('This request is non-blocking'); console.log('Finished non-blocking request'); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
/blocking-prime
にアクセスしてからすぐに
/non-blocking
にアクセスすると、
findPrimes
関数がメイン スレッドを専有しているため、
/non-blocking
の応答に大幅な遅延があることに気付くでしょう。
次に、これ
worker_threads
を使用してリファクタリングしましょう。
-
main.js
(メイン アプリケーション ファイル):// main.js - worker_threads を使用 const express = require('express'); const { Worker } = require('worker_threads'); const app = express(); const port = 3000; app.get('/worker-prime', (req, res) => { console.log('Received worker prime request on main thread'); // 新しいワーカー スレッドを作成します const worker = new Worker('./prime_worker.js', { workerData: { start: 2, end: 20_000_000 } }); // ワーカーからのメッセージをリッスンします worker.on('message', (result) => { const { primes, duration } = result; res.json({ count: primes.length, firstPrime: primes[0], lastPrime: primes[primes.length - 1], duration: `${duration}ms` }); console.log('Finished worker prime request on main thread'); }); // ワーカーからのエラーをリッスンします worker.on('error', (err) => { console.error('Worker error:', err); res.status(500).send('Error in worker thread'); }); // ワーカーの終了をリッスンします worker.on('exit', (code) => { if (code !== 0) { console.error(`Worker stopped with exit code ${code}`); } }); }); app.get('/non-blocking', (req, res) => { console.log('Received non-blocking request on main thread'); res.send('This request is truly non-blocking now!'); console.log('Finished non-blocking request on main thread'); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
-
prime_worker.js
(ワーカー スクリプト):// prime_worker.js const { parentPort, workerData } = require('worker_threads'); function findPrimes(start, end) { const primes = []; for (let i = start; i <= end; i++) { let isPrime = true; if (i <= 1) { isPrime = false; } else { for (let j = 2; j <= Math.sqrt(i); j++) { if (i % j === 0) { isPrime = false; break; } } } if (isPrime) { primes.push(i); } } return primes; } const { start, end } = workerData; const startTime = process.hrtime.bigint(); const primes = findPrimes(start, end); const endTime = process.hrtime.bigint(); const duration = Number(endTime - startTime) / 1_000_000; // ナノ秒をミリ秒に変換 // 結果を親スレッドに返します parentPort.postMessage({ primes, duration });
このセットアップでは、/worker-prime
にアクセスしてからすぐに
/non-blocking
にアクセスすると、
/non-blocking
リクエストがほぼ即座に応答し、素数計算がメイン イベント ループをブロックしなくなっていることが示されます。
主要な
worker_threads
コンポーネント:
Worker
クラス: メイン スレッドで新しいワーカー スレッドを作成するために使用されます。コンストラクタは、ワーカー スクリプトへのパスと、ワーカーに渡されるオプションのworkerData
オブジェクトを取ります。parentPort
(ワーカー内): ワーカー スクリプト内で利用可能なオブジェクトで、親スレッドへの通信チャネルを表します。データを返すにはparentPort.postMessage()
を使用します。workerData
(ワーカー内): ワーカー スクリプト内で利用可能なオブジェクトで、親スレッドのworkerData
オプションから渡されたデータが含まれます。worker.on('message', ...)
(親内): ワーカーから送信されたメッセージを受信する親スレッドのイベント リスナー。worker.on('error', ...)
およびworker.on('exit', ...)
: 堅牢なエラー処理とワーカーのライフサイクルの監視に重要です。
アプリケーション シナリオ
worker_threads
は、CPU バウンドの課題に直面している Node.js アプリケーションに最適です。一般的なユースケースとしては、次のようなものがあります。
- 複雑な数学的計算: データ分析、科学シミュレーション、財務計算。
- 画像およびビデオ処理: サイズ変更、透かし、フィルタリング、エンコーディング/デコーディング。
- データ圧縮/解凍: 大規模ファイルのジップ/解凍。
- ハッシュ化および暗号化: 暗号化操作。
- 重いデータ解析および変換: 大規模な CSV、JSON、または XML ファイルの解析。
- 機械学習推論: 事前トレーニング済みモデルの実行。
これらのタスクをワーカー スレッドにオフロードすることにより、メイン イベント ループは受信リクエストやその他の I/O 操作の処理を自由に行うことができ、Node.js アプリケーション全体の応答性とスループットが大幅に向上します。
結論
Node.js の
worker_threads
はゲームチェンジャーであり、従来シングル スレッド環境での CPU バウンド タスクへのアプローチを根本的に変えました。真の並列処理を可能にすることにより、開発者はマルチ プロセス アーキテクチャや外部サービスに頼ることなく、より堅牢で、パフォーマンスが高く、スケーラブルなアプリケーションを構築できます。
worker_threads
を採用することで、Node.js は CPU 集約型のワークロードに対する「シングル スレッド ボトルネック」のラベルを払拭し、現代のアプリケーション開発において、さらに汎用性が高く強力な選択肢となります。