銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

JavaScriptのWebWorkerでスレッド間のリモートプロシジャコール(RPC)を実装する

f:id:takamints:20160131175631p:plain
photo credit: Pallet via photopin (license)


ブラウザのJavaScriptでWebアプリをマルチスレッド化できる Web Workers API の基本と、Worker側を使ってスレッド間でのリモートプロシジャコールを実装して、ワーカースレッドのメソッドを呼び出して戻り値をコールバック関数で受け取れるようにする方法を書いています。

■■■ Table Of Contents ■■■

  1. Workerの基本1:ワーカースレッドとの通信
  2. Workerの基本2:スレッド間メッセージはコピーされる
  3. しかしメソッド(関数オブジェクト)はコピーされない(送信できない)
  4. 同じくクラスオブジェクトのメソッドも無理ですよ
  5. コピーではなく参照を渡す方法もある(らしい)
  6. スレッド間リモートプロシジャコール(RPC)の実装
  7. npm transworkerのご紹介
  8. おまけ1:スクリプト内でコンテキストを判断するには?
  9. おまけ2:関数定義は常にグローバルスコープなんだな

  10. その他ワーカーについては、MDNの「Web Worker を使用する」を参考にしてください。

  11. 以降で使用しているWorkerはDedicatedWorker (専用ワーカー)というもので、サブスレッドを生成したスレッドと一対一の通信を行えるものです。他にSharedWorkerというのもあります。



Workerの基本1:ワーカースレッドとの通信

メインスレッドとワーカースレッドの間での通信は、メッセージを使用します。

メインスレッド側スクリプト:

var wkr = new Worker("dedicated/worker/thread.js");

//ワーカーへメッセージ送信
var data = {key1:value1, ... };
wkr.postMessage(data);

//ワーカーからのメッセージを受信
wkr.onmessage = function(e) {
    console.log("main thread onmessage e:" + JSON.stringify(e));
}

ワーカー側スクリプト:

//メインスレッドのメッセージを受信する
onmessage =  function(e) {
    console.log("worker thread onmessage e:" + JSON.stringify(e));
}

//メインスレッドへメッセージを送信する。
var data = {key1:value1, ... };
postMessage(data);

簡単ですね。

メインスレッドでは、生成したワーカーのpostMessageとonmessageで、送受信が行えます。

ワーカー側では、グローバルコンテキストでpostMessageとonmessageを使用できます。

Workerの基本2:スレッド間メッセージはコピーされる

postMessage ~ onmessageでやり取りされるメッセージは、コピーされます。 スレッド間で同じオブジェクトを共有していないため、それぞれ安心して自由に使用できます。 (ただし、特別な方法でオブジェクトの参照を渡すことも可能(↓)です。)

しかしメソッド(関数オブジェクト)はコピーされない(送信できない)

少なくともChromeでは関数オブジェクトを送信できませんでした。 他のブラウザでは試していませんが、多分出来ないんじゃないかな。 関数オブジェクトを一旦文字列化してからevalすればよいと思われますが、パフォーマンス上は望ましくないと思います。

むしろ、関数オブジェクトを送信しなくてもよいように設計するべきだと思います。

同じくクラスオブジェクトのメソッドも無理ですよ

関数オブジェクトが送信できないので、当然のようにクラスオブジェクトのメソッドも送信できません。

しかし、データメンバーは送信されます。

送信できないオブジェクトをシリアライズするときに、あえて排除しているのだとすると、 パフォーマンス的にデメリットがあるかもしれません。 特に詳しく確認していませんが、そのうち調べてみようとは思っています。

コピーではなく参照を渡す方法もある(らしい)

Transferableインターフェースを実装するデータは、 コピーではなくオブジェクトそのものを送信できるようです。(⇒所有権の譲渡 (Transferable Objects) によるデータの引き渡し|MDN)) ただし、送信元では、それ以降そのオブジェクトを使えなくなります。文字通り渡してしまうようなイメージになります。

// 32MB の "file" を作成して埋めます。
var uInt8Array = new Uint8Array(1024*1024*32); // 32MB
for (var i = 0; i < uInt8Array.length; ++i) {
  uInt8Array[i] = i;
}
worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);

上のコードは、MDNのサンプルをそのまま引用しています。postMessageの引数が違っていますね。 第2引数は配列で、その要素は第一引数ということのようですが、これに実質的にどんな意味があるのかはよくわかりません。あまり直感的でないよう・・・。

  • Transferableインターフェースを実装しているクラスは、ArrayBufferMessagePort だけとのことです。

スレッド間リモートプロシジャコール(RPC)の実装

これまで書いたように、スレッド間メッセージは、それぞれが好きなときに送信でき、非同期で処理されますが、関数オブジェクトは転送できません。 そして、スレッド間のリモートプロシジャコールの機構も提供されていないようなので自前で用意しなくてはならないようです。

以下のフローは、サブスレッドで定義されるメソッドを呼び出し、戻り値をコールバックで受け取る処理を示しています。

(要求元)
1. 送信する要求メッセージとコールバック関数のセットに対して、新たなIDを割り当てます。
2. 送信元で、このコールバック関数を、IDをキーとしたハッシュに保存
3. 送信先へ要求メッセージとIDを送信

(ワーカー側)
4. メッセージを受信し、要求に対する応答メッセージを生成
5. 受信メッセージ内のIDと応答を返却

(再び要求元)
6. 応答を受信し、そこに含まれるIDでコールバックを検索。
7. 検索したコールバックのパラメータに応答データを指定して呼び出します。

npm transworkerのご紹介

上記フローを実装したモジュール transworker をnpmで公開しています。 概要は下図参照。詳細はREADMEに書いていますので、よろしくどうぞ。

TransWorkerの概念図

TransWorkerクラスは、下図(?)のように、シングルスレッドでのクラスメソッド呼び出しモデルの間に入って、そのクラスの処理を丸ごとサブスレッド側へ持っていくことです。 メソッドの戻り値が、コールバックで得られるということを覚えておけば、利用者側は細かいことを気にする必要はありません。


                      Single thread model
                      ===================

    +-----------+        call methods         +-----------+
    |Application|---------------------------->|ClientClass|
    +-----------+                             +-----------+
          ^                                         |
          |                 return                  |
          +-----------------------------------------+



             Multi thread model(using TransWorker)
             =====================================

                               |
           Main (UI) thread    |    Web Worker (sub) thread
                               |
              call methods     |     call methods
                   |           |          |
                   |   +---------------+  |
                   |   |    message    |  |
                   |   |    ------>    |  |
    +-----------+  V   |               |  V   +-----------+
    |Application|----->|  TransWorker  |----->|ClientClass|
    +-----------+      |               |      +-----------+
          ^            |    message    |            |
          |            |    <------    |            |
          |            +---------------+            |
          |               |    |    ^               |
          |    callback   |    |    |    return     |
          +---------------+    |    +---------------+
                               |

www.npmjs.com github.com

おまけ1:スクリプト内でコンテキストを判断するには?

上のクラスでやっているように、グローバルスコープの this.constructor.name でコンテキストが判断できます。 これが、'Window'なら、ブラウザのUI-スレッド(メインスレッド)で読み込まれていることを示しており、'DedicatedWorkerGlobalScope'なら、Workerだということです。

おまけ2:関数定義は常にグローバルスコープなんだな

Worker無関係ですが、グローバルコンテキストで処理を振り分け、ThenとElseブロックの両方で、同名の関数定義を行うと、後に定義された関数しか参照できません。

説明が分かりにくいって人、ごめんなさい。以下のスクリプトを見てください。

var context = this.constructor.name;
if(context == 'Window') {
  function ComWorker() {
    console.log("ComWorker for UI-thread");
  }
} else if(context == 'DedicatedWorkerGlobalScope') {
  function ComWorker() {
    console.log("ComWorker for Worker-thread");
  }
}

このスクリプトをメインスレッド側で読み込んだ場合でも、new ComWorker()を実行すると、コンソールに、"ComWorker for Worker-thread"が表示されていました。

関数定義の評価はスクリプト読み込み時に処理されて、グローバルコンテキストに定義されるんでしょうね。勘違いしやすいので気をつける。

あとがき

ブラウザで動作する往年の8ビットマイコン MZ-700エミュレータを作っており、 当初、インターバルタイマーを使って単一スレッドで実装したら速度が遅くて不安定。ブラウザが操作不能に陥ったりしていました。

そこで、小耳に挟んだWeb Workers APIを調査開始。 ワーカースレッド側でタイマーを使えば動作が安定。マシンスペックに応じて可能な限り高速に動作するようになりました。

現在エミュレータでは、Transferableインターフェースでデータを移譲する方法を使用していませんが、 サブスレッド側でクローンしてからメイン側へ移譲するなどすれば、 ある程度の速度向上に役に立ちそう。

関連サイト:

takamin.github.io takamin.github.io