Kogakuin Univ Advent Calendar 2021 22日目の記事です。
Node.jsでちょっと前[要出典]から使えるようになった、worker_threadsってやつを使って分散メモリ型?の並列プログラミングをしてマルチスレッドで動くプログラムを書く方法について書きます。SharedArrayBufferってやつを使うと共有メモリもできるらしいですよ。 なんかフロントサイド(ブラウザ)のJSでもWeb Workerなるものが使えて、似た感じで書けるっぽい[要出典]ので気になる人は調べてみてね(丸投げ)
JS完全に理解した人の理解度に合わせて書いてるので、 ()=>
とかに対して何ですかこの卑猥な形の記号列は?とかいう質問が飛んでくるのは想定してません(日本語訳: JSの文法(ESなんちゃら以降しか使えないやつも含む)に関する説明は省くよ)
書くの面倒なのでググってください
みんなで勝手に同じ机にアクセスするのが共有メモリ型、みんな自分の机にしかアクセスできなくて、集中管理するスレッドの机にデータを集めて、そいつがみんなとデータをやり取りするのが分散メモリ型です
Node.jsでマルチスレッド処理ができるモジュールです。ちょっと古いバージョン(aptとかで入るやつとか)で利用するには--experimental-worker
フラグをつける必要があります
雛形のコードを見てみましょう
node main.js
として実行すると、出力はこのようになります。
ただし各スレッドの起動にかかる時間は変動するので順番は毎回変化し、また実行したマシンで利用できるスレッド数(論理コア数)に応じて表示されるスレッドIDの数も異なるため、これはあくまで一例となります。
{ id: 3 }
{ id: 4 }
{ id: 5 }
{ id: 6 }
{ id: 0 }
{ id: 1 }
{ id: 2 }
{ id: 7 }
さて、コードの中身を見てみましょう。
main.js
の1行目ではworker_threadsモジュールからWorkerクラスのコンストラクタをインポートし、2行目では実行中のマシンで利用できる論理コア数を取得して生成するスレッドの数として設定しています。
4~6行目のfor文では0から順番にidを振っています。Workerクラスのインスタンスをnewすることでスレッドが生成されますが、この時に第1引数で実行するファイルを、第2引数でオプションを指定しています。
オプションはオブジェクトとして渡します。このコードで渡しているworkerDataオプションは、生成される子スレッドに渡すデータです。
このデータはコピーされるため、親スレッドで変更しても子スレッドには反映されず、その逆もまた然り、となります。(SharedArrayBufferを渡した時に限り、変更を共有できます。)
またここではworkerDataもオブジェクトになっていますが、workerData:id
のように、一部の型を除いて任意の値を指定することもできます。(一部の型:関数など。クローンできずにエラーとなります)
次に子スレッドの処理ですが、require('worker_threads').workerData
として親スレッドからのデータを受け取れるのでそれをインポートし、それをコンソールに出力した後に終了しています。なお子スレッドからの出力は親スレッドを通じて出力されるようで、文字色や背景色は全て無視されます。(普通は数字が黄色になったりするが、全部白文字)
全ての子スレッドが終了したタイミングで、親スレッドも終了します。
ちなみに、このコードでは親スレッドも含めると論理CPUよりも1つ多くスレッドが生成されますが、子スレッドがバリバリ計算して、親スレッドはたまにデータの送受信をするだけで負荷がほぼない、みたいな使い方を想定しているので、これで問題無いと判断しています。
子スレッドの生成時に親スレッドから子スレッドに送るだけじゃ作れるものは限られてきます。実行中にデータをやり取りするコードを見てみましょう。
parent->worker0 I'm parent
parent->worker1 I'm parent
parent->worker2 I'm parent
parent->worker3 I'm parent
parent->worker4 I'm parent
parent->worker5 I'm parent
parent->worker6 I'm parent
parent->worker7 I'm parent
worker1->parent I'm worker1
worker3->parent I'm worker3
worker5->parent I'm worker5
worker0->parent I'm worker0
worker6->parent I'm worker6
worker7->parent I'm worker7
worker2->parent I'm worker2
worker4->parent I'm worker4
先程のこードとの違いを見ていきます。
main.js
では、まずworkerからメッセージを受信した時の動作を指定するコード(5~7行目)が追加されています。メッセージの送信スレッドと受信スレッドの情報と共に、メッセージの内容を表示するというものです。そして8~10行目では、スレッドの生成後100ms後にスレッドに対してI'm parent
という文字列を送っています。
worker.js
でも同様に、3~5行目でメッセージ受信時の動作を定義し、200ms秒後に親スレッドに対してメッセージを送っています。尚、親スレッドはrequire('worker_threads').parentPort
として取得できます。そして更にその100ms後、つまりスレッド起動から200ms後にプロセスを終了しています。
余談ですが、親スレッドからのメッセージ送信のタイミングは子スレッドの起動時間の影響を受けないので綺麗にid順になることが多い(setTimeoutの発火タイミングがズレることもあるようで、必ずではない)のですが、子スレッドからの送信タイミングは起動時間の分ズレるので、順番がバラバラになります。
ここでは文字列を送信しましたが、起動時に渡すworkerData
と同様、基本的には任意のデータを送信できます。
ただ文字列の送受信をするだけではつまらないので、worker_threads
を使って実際にプログラムを書いてみましょう。
…ところがどっこい、この記事のために作ったプログラムが全部送受信データと計算量の比率が悪すぎて全然効率が上がらなかった… ということで、
まあ、わざわざ分担させるからには、重い処理をさせないと意味がないですよね。重い処理って、何があるでしょうか。例えば、配列を渡して合計を取る、とか?要素数に対してO(N)で増えていきますね。
これは、分散メモリ型のマルチスレッドとの相性はめちゃくちゃ悪いです。なぜかといえば、スレッド間で送受信する時間があれば計算が終わってしまうからです。Nが大きくなって1回の通信ごとの計算量が増えても、通信時間もO(N)なので同じこと。
共有メモリ型なら割と悪くないかも?排他処理(他のスレッドがいじってるなうなデータをいじらないようにすること)がとてもめんどくさそうだけど。
どんなのが向いてるかというと、割と少なめのデータを送るとめっちゃループを回した結果割と少なめのデータが返ってくる、そんなやつが向いていると思います。
俺くらいの技量でも実装できそうなやつっていうと、シミュレーションしか思いつかない。ただし、時系列での分割(t=0の時の計算はworker1、t=1の時の計算はworer2、みたいな感じ)はできません。なぜなら、t=0の時の結果が出ないとt=1の時の計算ができないからですね。じゃあどうやって分割するのかと言うと、パラメーター違いで計算させる。もしくは、何度も同じ状況で計算してもランダム値を使うため結果は毎回少し異なるようなシミュレーションの合計とか平均を取るような場合は全部同じ初期値からスタートさせて同時に計算させる。そういう処理が向いている、ということになります。
あいにく、条件と処理内容を説明しやすそうなシミュレーション内容が思いつかなくて…研究室の課題ならあるんだけど。これ載っけていいのかな。この垢のリポジトリ覗けば普通にpublicにしてるので見たい人はみてもいいんじゃない?知らんけど。→privateになりました