Node.js の worker_threads でマルチスレッド処理を行う

先日 Web Worker による別スレッド処理の方法を紹介したが、今度は Node.js で同様にマルチスレッド処理ができる、worker_threads という組み込みモジュールを紹介する。

目次

Node.js におけるプロセス・スレッドの概念整理

Node.js は通常、「シングル・プロセス」「シングル・スレッド」で実行される。

「プロセス」というのは平たくいえば「起動中の1アプリ」のことで、「シングル・プロセス」というのは「Node.js はその処理中にプロセスを1つしか作りませんよ」ということである。「マルチ・プロセス」で動くアプリといえば、Apache HTTP Server などが分かりやすいか。Google Chrome ブラウザなんかも、タブごとなどにマルチプロセスで動いている。

「スレッド」というのは、各プロセスが使用する CPU の単位。よく「4コア8スレッド CPU」などというが、CPU のコア数がイコールスレッド数だったり、最近はハイパースレッディング機能を持つ CPU がほとんどなので、CPU コア数の倍の数が「利用可能なスレッド数」とみなせる。Node.js の場合、1つのプロセスしか起動せず、その1つのプロセスの中でも、CPU を1スレッド分しか使わないで動作している、ということである。例えば2コア4スレッドの CPU 上で Node.js 製のアプリを動かすと、CPU 使用率は全体でも 25% 程度に留まる、というようなイメージである。

ただ、実際の Node.js 内部では、libuv によって必要に応じて自動的にマルチスレッド処理をしているようである (fs モジュールでのファイル I/O など)。そのために待機するスレッドも作られているようだ。まぁ Node.js を使ったスクリプトやアプリを書く人にとってはほとんど意識する場所ではなく、原則シングルスレッドで動いている、という考え方で問題ないかと覆う。

こういう仕組みなので、Node.js が複数の処理を同時に行おうと思った場合、「イベントループ」という仕組みで、あたかも同時並行に処理しているかのように見せているワケである。Promise や asyncawait によって実現される非同期処理は、実際は「並列 (Pararell)」ではなく「並行 (Concurrent)」処理なのである。両手で同時に絵を描いているのではなく、片手で2枚の紙にちょっとずつ絵を描いていっているような、そんなイメージ。w

Node.js には元々、child_process というモジュールで別プロセスを起動する方法があり、コレを応用した cluster というモジュールが用意されている。コチラは内部で child_process を利用しており、複数のプロセスを起動することで、それらのプロセスに異なる CPU スレッドを利用させることで負荷分散させていた。つまり 「マルチ・プロセス」で動作するのだが、各プロセスは「シングル・スレッド」で動作している、というのが cluster モジュールである。

Cluster モジュールについては過去記事で紹介しているが、Express のような HTTP サーバを負荷分散させるために使ったりするのが向いている。プロセスの起動というのは起動時にメモリを大きく消費するし、各プロセスが別々にメモリを確保するので、使い所は見極めないといけない。

ただ、最近は Kubernetes なんかにアプリをデプロイすることがあると思うが、その際は Pod の replicas でレプリカ数を指定して複数コンテナを起動させた方が、適切に負荷分散されることだろう (わざわざ Pod 内で cluster モジュールを使う必要はない)。Kubernetes Pod 内で require('os').cpus().length を見ると、K8s Worker Node の CPU スレッド数がそのまま見えるので、その数でプロセスを分離して負荷分散しようとすると、Pod 定義の resources.limits.cpu の設定とそぐわなくなってしまう。

では、今回紹介する worker_threads とはどういうモノかというと、「シングル・プロセス」「マルチ・スレッド」 で動作するコードが書けるようになるモノである。Cluster と違ってプロセスは一つ。それでいて CPU スレッドは複数使える。

コレの何が良いのかというと、「別プロセスを起動する」よりも生成コストが少なく済むことと、「各スレッドがメモリを共有できる」点にある。child_process および cluster によって別プロセスを起動した場合、そのプロセス間でのデータのやり取りは IPC という仕組みで、メッセージを送り合うことになる。メッセージは各プロセスで複製して保持されるので、大容量のデータを「共有」したい時にメモリを食うことになるワケである。一方、worker_threads の仕組みなら、平たくいえば「各々が同じグローバル変数を参照・操作できる」のに近い状況となり、大量データの受け渡しが低コストで済むワケである。

worker_threads は Node.js v10.5.0 頃に導入され、v11 以降は普通に利用できるようになっている。

worker_threads によってより軽量にマルチスレッド処理が実現できるようになるなら、cluster モジュールはもう要らないのではないか、というとそうでもない。Node.js は V8 の制限により、1プロセスが保持するヒープメモリは 1.5GB までとなっている。単一プロセスでメモリを共有する worker_threads の場合、使えるメモリは全体で 1.5GB まで、というのが原則となる。一方 cluster ならプロセスを分離しているので、その分だけメモリを多く利用できるワケである。だから HTTP サーバのようなモノならクラスタ化して複数起動しておくことで、CPU コア、大容量メモリを活用した負荷分散ができるというワケだ。

worker_threads はスレッド分割によって CPU バウンドな処理はより効率的に処理できるようになるが、「メモリが共有できる」という点は、処理内容によってはメリットにもデメリットにもなりうるということである。

はよコード見せろ

…話が長くなってしまったが、とりあえず worker_threads を利用するコードをお見せしよう。

const workerThreads = require('worker_threads');

// ココでは Worker を1つ生成する
const worker = new workerThreads.Worker('./worker.js', {
  workerData: 'From Main'  // Worker に初期値を渡せる
});

worker.on('message', message => {
  console.log(`[Main] Worker からメッセージを受信 : [${message}]`);
});
worker.on('error', error => {
  console.log(`[Main] Worker でエラーが発生 : [${error.message}]`);
});

console.log('[Main] 初回メッセージを送る');
worker.postMessage('Hello From Main');

setTimeout(() => {
  console.log('[Main] エラーを発生させるためのメッセージを送る');
  worker.postMessage('Please Error');
}, 2000);

続いて、上の main.js から呼び出される Worker 側のコード。

const workerThreads = require('worker_threads');

// うっかり `$ node ./worker.js` で起動された時に何もしないようにする
if(workerThreads.isMainThread) throw new Error('このスクリプトはメインスレッドでは利用できません');

// `new Worker()` の `workerData` で渡された値が参照できる
console.log(`[Worker] Init Worker Data : [${workerThreads.workerData}]`);

workerThreads.parentPort.on('message', message => {
  console.log(`[Worker] メインスレッドからメッセージを受信 : [${message}]`);
  
  // わざとエラーを投げるためのコード
  if(message === 'Please Error') throw new Error('Worker からエラーをスローします');
  
  // `main.js` に向けてメッセージを送信する
  workerThreads.parentPort.postMessage('Worker からメッセージを返信します');
});

こんな感じで、Web Worker に似たような postMessage() でのやり取りができる。メモリを共有するには SharedArrayBuffer などを使わないといけないので、この例では各メッセージは複製されているのだが。

CPU リソースを使う重たい処理を、CPU スレッドの数だけ分割して処理させ、各 Worker の計算結果を親スレッドが束ねる、みたいな感じで扱うと、マルチスレッドの恩恵が受けられそう。

おまけ:Intel Mac のアクティビティモニタ、CPU スレッドの半分が使われていないように見える件

今回の記事を書くために CPU に負荷をかけるようなコードを書いて色々と動作確認していたのだが、Intel CPU 搭載の MacBook Pro の「アクティビティモニタ」を見ていたところ、6コア12スレッドのうち、6スレッドしかアクティブに動いていなくて、残り6スレッドは全く使用されていないかのように見えていた。

Node.js や V8 エンジンのせいなのかと思い、Python など他の言語のコードでも負荷をかけてみたけど、やはり12スレッド中、奇数番目の6スレッドしか動作していないように見えていた。

似たような話をしている人もいるのだけど、M1 Mac ではハイパースレッディングをしなくなったから全コア使っているように見えてるらしく、コレといった原因がよく分からない。コレは何で?どゆこと?故障とかじゃないと思うんだけど、ワイの MBP はハイパースレッディング無効化されとるんか?笑〃 どなたか原因が分かる方、教えてくだしあ〜w