Node.jsのマルチスレッド処理:worker threadsのメリットとデメリット
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「スレッド」の間でメモリが暗黙的に共有されることはありません。代わりに、イベントベースのメッセージングシステムを使ってプロセス間で値をやり取りします。
第1回で詳しく見たこのコードは、メインスレッドから値(hello)を受け取り、別の形式(You said "hello".)で返すworkerプロセスを作成します。postMessage()関数は、メインスレッドとworkerスレッドの間でデータを送信するために使います。一方で設定した変数は、もう一方からは参照できません。
このルールには例外があります。SharedArrayBufferを使い、共有メモリとして明示的に割り当てることで、スレッド間でメモリを直接共有できます。
このコードをshared.jsに保存して実行すると、次の出力が表示されます。
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に保存します。
次に、このコードをworker.jsに追加します。
次のコマンドでPiscinaパッケージをインストールします。
node main.jsを実行すると、ターミナルにYou said helloと表示されます。Piscinaを使うと、worker threads APIをより簡単に扱えます。
Bree
BreeはNode.js向けのジョブスケジューラーです。指定した間隔で非同期タスクを実行できます。各タスクには、同時実行数の上限、再試行、キャンセルを設定できます。Breeは内部でworker threadsを使い、メインループの外でタスクコードを実行します。
次のコマンドでBreeをインストールします。
次に、以下のコードを含むbree-main.jsというファイルを作成します。
以下のコードをjobs/bree-job.jsに追加します。
node bree-main.jsを実行すると、時刻がすぐに表示され、その後は5秒ごとに表示されます。
Poolifier
Poolifierもworkerプールを実装するライブラリです。プールを自分で管理する複雑さを伴わずに、複数のworkerを扱えます。プールには、再利用するworker数が固定された固定型と、ユーザーが設定した上限に達するまで必要に応じてworkerが追加される動的型があります。
main.jsに以下のコードを追加すると、指定したファイルをworker threadで実行するシンプルなプールを作成できます。
fixed-worker.jsに以下の内容を追加して、固定型プールで実行するコードを定義します。
次に、動的型プールで実行するコードをdynamic-worker.jsに追加します。
npmからpoolifierパッケージをインストールし、Nodeでmain.jsを実行します。2つのスレッドプールが起動してジョブを実行すると、次のように表示されます。
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で十分です。


