2018年4月23日月曜日

javascriptのWorkerに触ってみた

まさか自分ごときがjavascriptでマルチスレッド処理をやろうと思うなんて夢にも思わなかったので覚書。
    Worker使用の条
  1. Worker構築直前まで、Worker用のコードはただのテキストだ
  2. メイン/ワーカースレッドはpostMessageだけが通信手段だ
  3. postMessageで通信する内容は連想配列、配列の配列など多彩に使える。但し自作クラスインスタンスはそのままでは使えない
  4. 言い換えればpostMessageで通信する内容はJSONオブジェクトなどで[デ]シリアライズできる範囲であればなんでもイケル
  5. 実際にやってみるとどうということはない
以上です。
以下タワゴトです。 まず、HTML5にはWorkerという別スレッドで実行させる仕組みがすでにあるそうです。
実際やってみたらInternet Explorer11でもChrome65でも全く問題なく動作してちょっと感動しました。

で、Workerって何だと。
まあ、スレッドです。
但し、豪華にメインスレッドとワーカースレッド間の通信機能付きなんですこれが。
お互いpostMessage()しあうだけで通信ができちゃう。

誤解を招く言い方をすると、Win32APIで例えるならば、メインスレッドとワーカースレッド間の通信にメールスロットやパイプを使う感じ。mutexやsemaphoreやcritical sectionでもってロックしてそのスキにオブジェクトのインスタンス丸ごといただき!!とはいかない。

というのは、複雑なオブジェクト、たとえばクラスっぽく実装したオブジェクトをやり取りすると、メソッド的な定義が全部undefinedになって受け渡されるので、結局はシリアル化可能な状態にして送受信しなきゃダメヨ、という制限があるということです。
※javascriptにクラス(ECMAScript 6 のsyntaxsugarを除いて)なんかない! => ありません。オブジェクト指向継承モデルはjavascriptにはありません。以下プロトタイプベースのナニカを指していると思って、その前提でお読みください。

別のたとえをすると、C++のクラスのインスタンスをfwrite()でそのままファイルに吐きました!fread()で持ってきました!!うごきません!!!みたいな。こういう言われ方をすると「動くわけねぇだろ」って言いたくなるでしょ?でも高級言語をもっぱらお使いの人たちは「動いて当然」とまず思い込むようです。いくらなんでもJSONやらXMLやらでシリアライズする処理なんか普段お手の物のはずなんですが、スレッド間通信はそうあってはならないらしい。

裏を返せばそれだけの話で、メインスレッド/ワーカースレッド間通信はプロセス間通信とかノード間通信でソケットなどでおしゃべりすることをイメージしたほうが早いです。

実際にC#で実装したコードをNetjsでjavascript化した総当たりトーナメント関数をjavascriptでもスレッド化して、長時間かかる処理の進捗状況と、終了した場合はその結果を受け取る、という処理を書くのに1時間もかかりませんでした。
全部MDN Web Docsさんのわかりやすいドキュメントのおかげです。

Bloggerではjavascriptを気軽にjsファイルとして外部化すると設置する別サーバが必要になるので、全部同一ソースで行う前提での例をご覧いただきます。

まず、すごい時間がかかる処理をやるだけのクラスがあるとします。FillFieldと名付けます。

で、ワーカースレッド用のスクリプトを書きます。
たとえば以下のように。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<script id="workerscript" language="text/worker">
    // 進捗報告用コールバック。cnt=現在完了済みの作業量
    function progresscallback(cnt) {
        postMessage({ type: 1, value: cnt });
        return true;
    }
    // 計算終了報告用コールバック。objは演算結果が入っているクラス
    function successendcallback(obj) {
        postMessage({ type: 2, value: obj.GetPatternList() });
    }
    onmessage = function (event) {
        // メインスレッドからメッセージを受信した際に呼ばれるイベントハンドラ。
        // ここでは何も考えずいきなり時間がかかる処理を開始している
        var workerFillFieldObject = new FillField(
            event.data[0],
            new CropData(event.data[1][0], event.data[1][1], event.data[1][2]),
            event.data[2],
            event.data[3],
            event.data[4],
            event.data[5],
            event.data[6],
            event.data[7]);
        var rate = workerFillFieldObject.CalculationAmount / 1000;
        if (rate > 10000) {
            rate = 10000;
        }
        // FillFieldクラスのコールバックにコールバック先を登録している
        workerFillFieldObject.SetCallBack(progresscallback, successendcallback, rate);
        // これがでたらめに時間がかかる処理本体
        workerFillFieldObject.MakeHumusPattern();
    }
</script>
このコード、多くをFillFieldをnewするだけに費やしていることを見ても、えらい楽勝でしょう?
実際にやっていることは、FillFieldから呼ばれるコールバック、進捗報告と作業完了報告のために呼ばれるものが2つ定義してあって、あとはFillFieldクラスをnewして目的のメソッド(MakeHumusPattern)を実行しているだけ。
コールバック関数ではメインスレッドにpostMessageを投げつけているだけです。
ご注意いただきたいのは<script id="workerscript" language="text/worker">としてありますが、languageの右辺値が出鱈目です。
言い換えると、ブラウザがlanguage=""の中身の解釈方法を知らない必要があります。ワーカースレッド以外で実行されてほしくないからです。
右辺値にjavascriptとか書いてはいけません。ブラウザはその解釈方法を知っているからです。別にscriptでなくてもなんでもよくて、中身のテキストだけが必要なので、実際にはhiddenなdivでもかまいません。当然、最終的にはjavascriptとして実行されますので何でもいいといってもテキストの中身は和文でも英文でも中文でもだめでjavascriptでなければなりません。

onmessageにいきなりメソッドを代入していますが、メインスレッドからpostMessageされた際の処理なので、複数のメッセージをメインスレッドから飛ばしたいなら工夫してください。
たとえば、上記の例でもワーカースレッドからメインスレッドに対して2種類のメッセージを連想配列(キーはtypeとvalue)を使用して発行しています。当然伝えたいのはvalueの右辺値ですが、そのvalueが何を意味しているのかを簡単にわかるようにするためtypeに番号を振ってあります。
まあ、通常、メインスレッドからのメッセージは「開始せよ」「一時中断せよ」「再開せよ」「死んでしまえ」くらいですかね。この場合は「開始せよ」というメッセージ以外想定していないコードです。

では、次にメインスレッド側でのスレッド起動処理を書きます。
まず、外部ファイルを使わない前提なので同一ソースから全部ワーカースレッドを実行するための「テキスト」を拾ってきます。
先ほど定義した<script id="workerscript" language="text/worker">は、そのテキストのうち、ワーカースレッドとして必要な定義を記述してありました(今のところブラウザにも解釈されない単なるテキストです)。
そのほかに、そもそもFillFieldクラスの定義も必要です。それは<script id="class_define_script" language="javascript">としてメインスレッドでも使えるように定義してあるものとします。

メインスレッドで見えるようなクラス定義になっていても、ワーカースレッドさんは見えません。そのため、ワーカースレッド用にも同一のソースファイル(のテキスト)が必要となります。そのため、二か所に記述するのは間抜けなので、すでにメインスレッド用に定義してあるスクリプトを文字列として取り出すためclass_define_scriptというidを振ってinnerHTMLでテキストを吸い出す作戦です。

workerScript = workerscriptの中身の文字列 + class_define_scriptの中身の文字列;
var blob=new Blob([workerScript], { type: "text/javascript" });
でまずBlobを作っちゃいます。読んだ通り元ネタは文字列をくっつけただけです。
そのblobのURLを作って、そのURLからWorkerを作ります。
worker = new Worker(window.URL.createObjectURL(blob));
これだけです。

これで無事Workerができたので、workerでFillFieldクラスをnewできるだけの情報をpostMessageします。
ちょっとややこしい例になっていますが、ただの配列や配列の配列などは問題なくpostMessageでやり取りできる例としてみてください。postMessageしている内容は配列ですが、配列の中に配列があったりします。これでもOKです。
これで先ほど定義したWorker側のonmessageが呼ばれ(FillFieldがnewされ)処理が開始されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function exec() {
  // 一つ目のソースのテキストを取得
  var class_define_script = document.getElementById("class_define_script").innerHTML;
 
  // 上記テキストと2つ目のテキストを足す
  var workerScript = class_define_script + document.getElementById("workerscript").innerHTML;
  // テキストからBlobを作り、URLを作り、Workerを構築
  worker = new Worker(window.URL.createObjectURL(new Blob([workerScript], { type: "text/javascript" })));
  // Worker側でFillFieldクラスがnewできるように情報を送る
  worker.postMessage([fields_num,
    [cd.crop_name, arr, cd.manual_bonus],
    expectedCropYield,
    100,
    USE_HUMUS_FLAG[1],
    USE_HUMUS_FLAG[2],
    USE_HUMUS_FLAG[3],
    USE_HUMUS_FLAG[4]]);
  // Workerからメッセージを受信するためのイベントハンドラを定義
  worker.onmessage = function (event) {
    if (event.data.type == 2) {
      // Workerが成功終了した場合に受信する
        makelisttext();
        document.getElementById("button_exec").disabled = "";
    }
    else {
      // Workerから進捗報告を受けた際に受信する
        document.getElementById("exec_resultarea").innerHTML = "現在" + event.data.value + "%";
    }
  }
}

次にメインスレッド側でワーカースレッドからメッセージを受け取るために、worker.onmessageハンドラにイベントリスナを登録します。
先のWorker用コードではFillFieldクラスには進捗報告コールバック機能があるので、そのコールバック関数にそのままメインスレッドへのpostMessageを呼び出すだけの関数を与えてありました(FillField.SetCallBack())。このリスナでそのメッセージを受信を行うことができるようになります。
引数はeventだけのシンプルなものです。
先ほども見たように、ワーカースレッドからはメインスレッドに対してpostMessageのeventとして連想配列を投げつけてきます。進捗状況通知ならtype=1,終わりならtype=2といったように。
その連想配列がこのeventの中身です。
連想配列のキー名"type"という名前にイベント番号、キー名"value"という名前に実際の値を突っ込んどけば何種類のメッセージだってswitch文だけで処理できるサンプルになるというわけです。ここでは2種類しかないのでifなんですけど。

この際も、メインスレッド側でクラスのインスタンスを生成してそれをワーカースレッドに渡さなかったように、処理結果もワーカースレッドのクラスのインスタンスをそのままメインスレッドに投げてもそのままそのインスタンスのオブジェクトは利用できません。
ただ、クラスのインスタンスだとするとメソッドを除けば単純コピーされているので、それを再利用すると可読性は落ちますが手っ取り早いかもしれません。
たとえば、List<T>オブジェクトがあったとして、List.CountやList.Addなどのメソッドはundefinedとして飛んできますが、List<T>内部でarrayで管理していた場合、実際にはそのarrayはしっかりコピーされて飛んできます。それを再利用するということです。
自前で管理クラスなどを作るより、はるかに楽だと思います。C#等の言語とは違い、クラスメンバにprivateもpublicもprotectedも、アクセス制限はなーんにもないのでアクセスし放題です。
まあ、しつこいようですが可読性はガックリ下がりますので、その辺はある程度覚悟(二度とメンテしないと決意したとか)が必要になるかもしれません。

文章にすると長くなるなあ。まとめる能力が欲しい。
お伝え出来たという歯ごたえがまったくありませんが、実際にやってみると簡単ですよ、ということで、一つよろしくお願いいたします。

なお、実際の上記の例の実行例はこちらの記事です。

ここまでお読みいただきありがとうございました。

0 件のコメント:

コメントを投稿