Skip to main content

Node.jsのマルチスレッド処理:worker threadsのメリットとデメリット

著者
Headshot of James Walker

James Walker

2023年2月27日

0 分で読めます

Node.jsでは、アプリケーションにシングルスレッドのイベントループが提供されます。そのため、CPU負荷の高い処理がメインスレッドをブロックし、遅延を引き起こすことがあります。worker_threadsモジュールは、スレッドを使った並列実行の仕組みを提供し、この問題に対処します。

前回の記事では、worker threadsとは何か、一般的な用途、プロジェクトへの追加方法を説明しました。この記事では、worker threadsを使う際の落とし穴や、ほかのプログラミング言語のマルチスレッド実装との違いを解説します。また、worker_threadsモジュールを使いやすくする、代表的な5つのライブラリも紹介します。

Worker threadsの落とし穴と注意点

worker threadsのメリットは簡潔に説明できます。Node.jsでマルチスレッドに近い処理を実現する唯一の方法です。CPU負荷の高い処理やバックグラウンド処理、非同期I/O以外の並列コード実行は、worker threadsを使って実装できます。

ただし、このモジュールと実装されている仕組みには、いくつかの注意点があります。workerを実装する前に把握しておきましょう。この仕組みで並列化すべきでない状況もあります。

Worker threadsは本当のスレッドではない

worker threadsの最も大きな制約は、一般的な意味でのスレッドではないことです。本格的なマルチスレッドアプリケーションでは、複数のスレッドがデフォルトで同じ状態を共有しながら同時に実行されます。Node.jsでは、あるスレッドで更新されたメモリはほかのスレッドから見えません。マルチスレッドコードを実装するには、競合状態を防ぐためにメモリを慎重に管理する必要があります。

Node.jsのworker threadsは、メインプロセスのJavaScriptコードから独立して動作します。Node.jsのV8 JavaScriptランタイムの分離されたインスタンスを起動して処理します。この新しいランタイムを使って、メインのイベントループの外でJavaScriptファイルを実行できます。

このようにファイルを実行するため、メインプログラムとworker「スレッド」の間でメモリが暗黙的に共有されることはありません。代わりに、イベントベースのメッセージングシステムを使ってプロセス間で値をやり取りします。

javascript
const {
    Worker,
    isMainThread,
    parentPort,
    workerData
} = require("worker_threads");

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: "hello"});
    worker.on("message", msg => console.log(`Worker message received: ${msg}`));
    worker.on("error", err => console.error(error));
    worker.on("exit", code => console.log(`Worker exited with code ${code}.`));
}
else {
    const data = workerData;
    parentPort.postMessage(`You said \"${data}\".`);
}

第1回で詳しく見たこのコードは、メインスレッドから値(hello)を受け取り、別の形式(You said "hello".)で返すworkerプロセスを作成します。postMessage()関数は、メインスレッドとworkerスレッドの間でデータを送信するために使います。一方で設定した変数は、もう一方からは参照できません。

このルールには例外があります。SharedArrayBufferを使い、共有メモリとして明示的に割り当てることで、スレッド間でメモリを直接共有できます。

javascript
const {Worker, isMainThread, parentPort} = require("worker_threads");

// Allocate memory for 4 integers
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 4);
const arr = new Int32Array(sab);

if (isMainThread) {
    const worker = new Worker(__filename, {workerData: arr});
    worker.on("message", msg => {
   	 if (msg.type === "update") {
   		 console.log(arr);
   	 }
    });
    worker.postMessage({type: "init", arr});
}
else {
    parentPort.on("message", msg => {
   	 if (msg.type === "init") {
   		 msg.arr[0] = 1001;
   		 parentPort.postMessage({type: "update"});
   	 }
    });
}

このコードをshared.jsに保存して実行すると、次の出力が表示されます。

$ node shared.js
Int32Array(4) [ 1001, 0, 0, 0 ]

worker threadがメインスレッドで作成された共有メモリ領域にアクセスできるため、このように動作します。メインスレッドが後でデータを確認すると、workerが書き込んだ変更が反映されています。ただし、これは状態が真に共有されているわけではありません。workerDataコンストラクターオプションを使って、配列をworkerから利用できるよう手動で渡す必要があるためです。

worker threadsを増やしすぎるとコストがかかる

worker threadの作成は、マルチスレッド言語で新しいスレッドを起動するのとは異なります。各worker threadは独自のV8 JavaScriptエンジンのインスタンスを実行するため、workerを使いすぎるとホストのリソースを大量に消費します。

workerの起動は速いものの、必ずオーバーヘッドが発生します。比較的コストの高い処理なので、軽量な処理には向きません。プロセス起動のコストを十分に上回るパフォーマンス向上が見込める、CPU負荷の高い処理の並列実行に使うのが最適です。

worker threadのプールを再利用すれば、workerを毎回新規作成するコストを避け、効率の悪さを軽減できます。PiscinaやPoolifierなどのライブラリを使うと、workerプールの管理を簡素化できます。

I/Oにworker threadsを使うのは非効率

worker threadsの特性上、I/Oタスクには適していません。ファイルの読み込みやネットワーク経由のデータ取得にworker threadsは不要です。Node.jsには、より優れた非同期処理の方法がすでに組み込まれています。

worker_threadsのドキュメントでも、こうした状況でのモジュールの使用を明確に推奨していません。独自のV8エンジンを持つworkerプロセスの作成と維持にかかるコストは、Node.jsの非同期I/O実装よりもはるかに非効率です。これらのタスクをworker threadsとして実装すると、パフォーマンスが低下し、リソースを浪費するうえ、冗長なコードを書くことになります。

Worker threadsのデバッグは難しいことがある

worker threadのプールは、イベントと、それを処理するworker、そして発生する結果の対応関係が明確でない場合があり、デバッグが難しくなることがあります。console.log()文を使って状況を調べるのは、手間がかかり、エラーも起きやすい方法です。

プールにAsyncResourceを関連付けると、より有用な診断情報を取得できます。プール内での処理を追跡する完全な非同期スタックトレースが得られ、結果が発生するまでの一連の処理をすべて確認できます。

SharedArrayBufferによるメモリ共有にも、問題が発生する可能性があります。共有メモリへのアクセスや変更時に競合状態が起きないよう、アトミック操作を使うか、独自の並行処理管理システムを実装する必要があります。競合状態が発生すると、アプリケーションに不可解な症状が現れることがあり、特に多くの場所で使われるメモリに関連する場合は、原因の特定が困難です。

Node.js向けの主要なスレッドライブラリ

Node.js組み込みのworker_threadsモジュールは、worker threadの作成とデータ交換という基本機能に重点を置いています。ここでは、より使いやすいインターフェースやスレッドプールなどの高度な機能を提供する、モジュールをラップした人気ライブラリを5つ紹介します。

Piscina

Piscinaを使うと、workerプールを簡単に扱えます。独自のタスクキューを作成して完了状況を追跡したり、不要になったタスクのworkerでの実行をキャンセルしたりできます。

Piscinaの簡単な例を見てみましょう。このコードをmain.jsに保存します。

javascript
const Piscina = require("piscina");
const piscina = new Piscina({filename: __dirname + "/worker.js"});

(async function() {
    const result = await piscina.run({msg: "hello"});
    console.log(result);    // "You said hello"
})();

次に、このコードをworker.jsに追加します。

javascript
module.exports = ({msg}) => `You said ${msg}`;

次のコマンドでPiscinaパッケージをインストールします。

$ npm install piscina

node main.jsを実行すると、ターミナルにYou said helloと表示されます。Piscinaを使うと、worker threads APIをより簡単に扱えます。

Bree

BreeはNode.js向けのジョブスケジューラーです。指定した間隔で非同期タスクを実行できます。各タスクには、同時実行数の上限、再試行、キャンセルを設定できます。Breeは内部でworker threadsを使い、メインループの外でタスクコードを実行します。

次のコマンドでBreeをインストールします。

$ npm install bree

次に、以下のコードを含むbree-main.jsというファイルを作成します。

javascript
const Bree = require("bree");

const bree = new Bree({
    jobs: [
   	 {
   		 name: "bree-job",
   		 interval: "5s",
   		 timeout: 0
   	 }
    ]
});

(async () => {
    await bree.start();
})();

以下のコードをjobs/bree-job.jsに追加します。

javascript
const d = new Date();
console.log(`The time is ${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`);

node bree-main.jsを実行すると、時刻がすぐに表示され、その後は5秒ごとに表示されます。

Worker for job "bree-job" online
The time is 11:45:30
Worker for job "bree-job" exited with code 0
Worker for job "bree-job" online
The time is 11:45:35
Worker for job "bree-job" exited with code 0

Poolifier

Poolifierもworkerプールを実装するライブラリです。プールを自分で管理する複雑さを伴わずに、複数のworkerを扱えます。プールには、再利用するworker数が固定された固定型と、ユーザーが設定した上限に達するまで必要に応じてworkerが追加される動的型があります。

main.jsに以下のコードを追加すると、指定したファイルをworker threadで実行するシンプルなプールを作成できます。

javascript
const {FixedThreadPool, DynamicThreadPool} = require("poolifier");

// 4 fixed workers
const fixedPool = new FixedThreadPool(4, __dirname + "/fixed-worker.js");
fixedPool.execute({}).then(res => {
    console.log(res);
}).catch(e => {
    console.error(e);
});

// Between 2 and 12 workers, dynamically
const dynamicPool = new DynamicThreadPool(2, 12, __dirname + "/dynamic-worker.js");
dynamicPool.execute({}).then(res => {
    console.log(res);
}).catch(e => {
    console.error(e);
});

fixed-worker.jsに以下の内容を追加して、固定型プールで実行するコードを定義します。

javascript
const {ThreadWorker} = require("poolifier");

module.exports = new ThreadWorker(
    () => "Running in the fixed thread pool",
    {
   	 async: false
    }
);

次に、動的型プールで実行するコードをdynamic-worker.jsに追加します。

javascript
const {ThreadWorker} = require("poolifier");

module.exports = new ThreadWorker(
    () => "Running in the dynamic thread pool",
    {
   	 async: false
    }
);

npmからpoolifierパッケージをインストールし、Nodeでmain.jsを実行します。2つのスレッドプールが起動してジョブを実行すると、次のように表示されます。

Running in the fixed thread pool
Running in the dynamic thread pool

Ctrl+Cを押して終了するまで、プロセスは実行を続けます。Poolifierは新しいタスクを処理できるようスレッドプールを維持し、プールが残っている間はプロセスが終了しないようにします。

Worker threadsとほかのプログラミング言語との比較

マルチスレッドの実装方法は、プログラミング言語によって異なります。Node.jsでは、worker_threadsモジュールが提供するマルチプロセス方式を採用しています。ほかの言語では、どのような仕組みが使われているのでしょうか。

  • C/C++:低水準言語であるCとC++は、どちらもPOSIXスレッドライブラリpthreadsを使って真のマルチスレッドを実現します。C++には標準名前空間にスレッドオブジェクトも用意されており、より簡単に並行処理を実装できます。競合状態を避けるには、アトミック操作やミューテックスを使ってメモリを適切に同期する必要があります。

  • Java:JavaもThreadクラスやRunnableインターフェースを使って真のマルチスレッドを実現できます。作成したスレッドを管理するための、充実した並行処理機能も備わっています。

  • Python:Pythonにはthreadingライブラリがありますが、最も広く使われているPythonの実装であるCPythonでは、一度に実行できるスレッドは1つだけです。そのため、スレッド機能が使えるように見えても、CPU負荷の高いコードの実行速度は向上しません。PythonにはNode.jsのworker threadsに似た方式を使うmultiprocessingモジュールもあります。

  • Ruby:RubyもPythonと同様の状況です。言語としては包括的なスレッドサポートを備えていますが、最も広く使われているMRI Rubyでは一度に1つのスレッドしか実行できません。一方、JRubyやRubiniusなど、より新しい代替インタープリターは真のマルチスレッドをサポートしています。

  • Rust:Rustは充実したマルチスレッドサポートを備えています。スレッドの作成やスレッド間でのデータ共有を、エラーのリスクを抑えながら簡単に行えます。言語の設計によって一般的な並行処理のバグの多くを防げるため、マルチスレッドを多用するプロジェクトに適しています。

C/C++、Rust、Javaのスレッド処理は、Node.jsのサブプロセス方式よりもはるかに高速です。これらの言語では、状態を共有する実際のスレッドを使える一方で、それに伴うメモリ管理上の懸念も生じます。Python、Ruby、Node.jsのような高水準のインタープリター言語にはネイティブなスレッド実装がなく、代わりに、より負荷の大きいworkerサブプロセス方式を採用しています。

まとめ

worker threadsを使うと、Node.jsの開発者は新しい子プロセスを起動してコードを並列実行できます。ただし、これは真のマルチスレッドではありません。それぞれの「スレッド」は独立したプロセスであり、親プロセスのコンテキストにはアクセスできません。スレッド間の通信には、割り当てた共有メモリか、イベントリスナーを介して交換するメッセージを使います。

worker_threadsモジュールは、今もNode.jsエコシステムに欠かせない存在です。同期的に処理をブロックする言語であるJavaScriptの制約内で、マルチスレッドや並列処理を実現する方法はほかにありません。ただし、worker threadsをいつ使うべきかを適切に判断できるよう、その制約を理解することが重要です。不適切な状況でworkerを追加すると、アプリのパフォーマンスが低下し、リソース消費が増える可能性があります。

パフォーマンスが重要となる、CPU負荷が非常に高いコードは、ほかのプログラミング言語の真のスレッドを使うほうが効率よく実行できます。一方、Webアプリのジョブキューやデスクトップでのバックグラウンド動画処理など、ほとんどのNode.jsの用途ではworker threadsで十分です。

続きを読む

blog feature ai lilac
Blog

エージェント型修正で脆弱性バックログをプルリクエストに変える

Evo Remediation Agentがオープンプレビューで利用可能になり、Evo Agentic AppSecに依存関係の自動解決機能が加わりました。AppSecチームは継続的な修正キャンペーンを実施できるようになりました

AI Background min crop
Blog

AIが、あなたが受け入れてきたあらゆるリスクを一気に顕在化させる:AI Security Panel: Unfilteredから得た7つの洞察

AIによって攻撃はより安価かつ高速になり、組織がすでに抱えているリスクが露呈しています。4人のセキュリティリーダーが、機械の速度に対応する防御と迅速な修復が今不可欠である理由を語ります。

blog feature ai violet
Blog

VulnBump:AIエージェントはビルドを壊さずに脆弱な依存関係を修正できるか?

VulnBumpでは、実際の依存関係のアップグレード307件をAIエージェントで検証します。脆弱性が解消され、ビルドとテストが正常に通った場合にのみ、修正成功と判定します。