読者です 読者をやめる 読者になる 読者になる

銀の弾丸

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

ブラウザでマルチスレッド ― JavaScriptのWeb Worker を使用してサブスレッドと通信する(応答をコールバックで受けとる機構を実装する)

ブラウザで動作する往年の8ビットマイコン MZ-700フルJavaScriptエミュレーターを作っております(⇒MZ-700フルJavaScriptエミュレータ)。

まだ完璧ではないのですが、かつての著名なゲームのいくつか(ワンダーハウス、ビルディングホッパー等)が、ある程度(途中までw)動作するようになってきました。

当初、window.setInterval を使った定期処理で複数命令をエミュレートしていたのですが、思った以上に速度が遅く不安定。ブラウザが操作不能に陥ったり。

そこで、Web Workers APIを利用してマルチスレッド化したところ、動作が安定し、可能な限り(マシンスペックに応じて)高速に動作するようになりました。

メデタシメデタシ・・・

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



ということで、今回Web Workers APIを調べて使って得た知識などを、ここに書いておきます。いくつになってもお勉強です。

■■■ Table Of Contents ■■■

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

ご注意:

  • このページに掲載しているコードは全てChromeだけで試しており、他のブラウザでは動作しないか別の挙動を示す可能性があります。
  • ワーカーについての最も初歩的なことは、MDNの「Web Worker を使用する」を参考してください。
  • 今回使用した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 だけということです。

ワーカースレッドへの問い合わせを同期する(応答をコールバックしてもらう)には?

スレッド間メッセージはそれぞれが好きなときに送信できて、非同期で処理されます。

しかし、要求メッセージを送信して、その結果をコールバックで受け取るといった処理は自前で用意しなくてはならないようです。 これは、以下のようなフローになります。

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

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

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

ということで、これを実装ししたちょっと便利なクラスを、以下のリポジトリに置いています。 使い方の詳細はREADMEに書いていますので、よろしくどうぞ。

github.com

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     |
          +---------------+    |    +---------------+
                               |

おまけ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"が表示されていました。

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


参考サイト:

MZ-700フルJavaScriptエミュレータ|たかみんつ