【JavaScript】Web Workersと戦う

このブログには(リンク記事も含め)約300件ほどの記事がたまっています。

左側にある最新記事一覧やタグ一覧はJavaScriptTumblrAPIを叩いてとってきているんですが、これがですね、流石に重過ぎるだろと。

もう5兆回ぐらい言っているんですが、Tumblrにはタグ一覧を取得する機能がありません。初回読み込み時には20件ずつしこしことリクエストを発行、全件取得してから更に編集、表示と言うプロセスを踏んでいます。

一連の動作はほとんどすべて同期処理で行われています。他のJavaScriptを読み込んだり、実行したり、HTMLのレンダリングもやっているスレッドでやらざるを得ないので、一瞬画面が固まったりするのは大体そのせいです。

しかしJavaScriptにはマルチスレッド処理がないのでどうにもならない…と思っていたんですが、Web Workersと呼ばれる概念を使うことで可能だと言うことを最近知りました。

と言うわけで今回はWeb Workersの基本的な使い方、ハック、そして敗北について書いていきます。

Web Workersの使い方(基本)

使い方は簡単のようで簡単ではありません。少なくとも、近年の各言語のような手厚いサポートは得られません。

まずはWorkerオブジェクトを手に入れます。

var worker = new Worker("background.js");

コンストラクタにはバックグラウンドで実行するタスクを記述したファイルパスを指定します。このファイルは同一生成元ポリシーを守る必要があります。これに違反するとSecurityErrorが発生してしまいます。

オブジェクトを作成しただけではタスクは実行されません。Worker.postMessageを実行することでキューを送ります。

var worker = new Worker("background.js");
// background.jsの非同期実行
worker.postMessage();

タスクとして指定されたJavaScriptファイルはself.onmessageと言うプロパティにファンクションを設定しておくことでWorkerからのキューを受け取ることが出来ます。

// background.js内
self.onmessage = function(e) {
    // TODO:非同期処理
};

この時、Worker側からタスクへ引数を渡すことが出来ます。

var worker = new Worker("background.js");
// background.jsの非同期実行
worker.postMessage({ msg: "hoge", datas: ["fuga", "piyo"] });

// background.js内
self.onmessage = function(e) {
    // onmessageに設定するファンクションの[引数].dataに
    // Workerから送信されたデータが入っている
    var data = e.data;
};

JSONが送れるので大体どうにかなりますが、ファンクションとかクラスとかそう言うものは送れません。なんか値型っぽいものだけ送れる、みたいな認識でいいと思います。

逆にタスク側からWorkerにpostMessageを発行することも出来ます。Workerにもonmessageプロパティを設定しておくことで、タスク側からのキューを受け取ることが出来ます。

// background.js内
self.onmessage = function(e) {
    var data = e.data;
    
    self.postMessage(data.msg + data.datas[0]);
};

var worker = new Worker("background.js");

// タスクの結果を受け取るListener
worker.onmessage = function(e) {
    alert(e.data);
};

// background.jsの非同期実行
worker.postMessage({ msg: "hoge", datas: ["fuga", "piyo"] });

勿論onmessageはaddEventListenerで代替可能です。

また、postMessageで渡すオブジェクトは値をコピーして渡すので、あんまり重たいものを何度も繰り返し渡すのは性能的に好ましくありません。Transferable Objectsを使うと高速化できるらしいのですが、対応しているブラウザのバージョンが微妙に高いのが難点です。

Web Workersの使い方(応用)

Workerに指定されたタスク内で発生した例外は基本的に消滅します。握りつぶすでも、親スレッドに伝播するでもありません。消えます。

消えてしまいますが、未処理の例外が発生した時点でタスクの実行は終了してしまいます。そうなれば当然WorkerへpostMessage出来ないので、終わったのか終わってないのかわからないまま終了していまいます。

もし親スレッドに伝播させたい場合はWorkerにonerrorを設定しておきましょう。そうすれば親スレッドで受け取ることができます。(勿論これもaddEventListenerで代替可能です。)

var worker = new Worker("background.js");

// タスクの結果を受け取るListener
worker.onmessage = function(e) {
    alert(e.data);
};

// エラーハンドラ
worker.onerror = function(e) {
    alert(e.filename + ":" + e.lineno + "\r\n" + e.message);
}

// background.jsの非同期実行
worker.postMessage({ msg: "hoge", datas: ["fuga", "piyo"] });

また、タスクからWorkerへは何度でもpostMessageを送りつけることができます。(逆もまた然りだが滅多にやることはないと思う。)これにより現在の進捗状況などを親スレッドに通知できます。

タスクが完了したらWorkerはきちんとterminateしておきましょう。

var worker = new Worker("background.js");

// タスクの結果を受け取るListener
worker.onmessage = function(e) {
    var data = e.data;
    
    switch(data.status) {
        case "progress" :
            console.log(data.msg);
            break;
        case "finish" :
            alert(data.msg);
            worker.terminate();
            break;
    }
    
};

// background.jsの非同期実行
worker.postMessage();

// background.js内
self.onmessage = function(e) {
    
    self.postMessage({ status: "progress", msg: "10%" });
    self.postMessage({ status: "progress", msg: "20%" });
    
    self.postMessage({ status: "finish", msg: "end." });
};

Web Workersの制約

Web Workersの使い方はこんな感じです。コードをちょっと改良するだけで使えそうな気がしてきますが、そんなことはありません。既存のコードはよっぽど上手く設計していないと再利用できません。

と言うのも、タスク側は非常に多くの制約を課せられているからです。具体的には以下の機能以外のことはできません

これはつまり、documentwindowと言った、JavaScriptの根幹とも言える基本的な機能にアクセスできないと言うことです。DOM操作も出来ない(=JSONPも使えない)と考えていいです。

documentwindowって別にJavaScriptの根幹機能じゃなくね?」と言う指摘を見かけたので訂正。「クライアントサイドJSにおいてほとんどどのような場所でも普通に使えると思ってしまいがちなwindow、またwindowを省略して呼べるメソッドやオブジェクト(alertとかdocumentとか)は使えません。」

ただし、先述した通りnavigtorやlocation、他にもsetInterval、setTimeoutなどの一部機能は使用することができます。要は「別スレッドでUI(DOM)に触るな」ってことです。わりといつものことですね。

呼び出し元のグローバルスコープとWeb Workersのグローバルスコープ

何よりも一番大きな問題は、Web Workersで呼び出したタスク内のグローバルスコープは呼び出し元のグローバルスコープとは別物になると言うところです。だって、Windowオブジェクトがないんだもの。

具体的に言うと、以下のコードは実行できません。

self.hogehoge = funcion () { return "hoge"; };

var worker = new Worker("background.js");
// background.jsの非同期実行
worker.postMessage();

// background.js内
self.onmessage = function(e) {
    // ここのスコープではhogehogeと言うメソッドが登録されていないので
    // 実行できない
    self.postMessage(self.hogehoge());
};

もしこれをどうしても実行したいと思ったら、background.jsでもhogehogeと言うファンクションを登録するしかありません。

// background.js内
self.hogehoge = funcion () { return "hoge"; };

self.onmessage = function(e) {
    self.postMessage(self.hogehoge());
};

先ほど紹介したWeb Workersで出来ることの中に「Workerの作成」がありました。タスク内で呼ばれたタスクも、やはりグローバルスコープは別物になります。

// background.js内
self.hogehoge = funcion () { return "hoge"; };

self.onmessage = function(e) {
    
    var worker = new Worker("background-2.js");
    worker.postMessage();
};

// background-2.js内
self.onmessage = function(e) {
    // ここのスコープではhogehogeと言うメソッドが登録されていないので
    // 実行できない
    self.postMessage(self.hogehoge());
};

じゃあbackgorund-2.jsにもhogehogeを登録する?ってなると、もう嫌になってきます。

これぐらい単純な例ならそれもいいかもしれませんが、全ファイルに何度も同じようなメソッド・定数・クラスを登録していくと当然修正のコストが跳ね上がります。これを解決するための仕組みとして、importScriptsメソッドが用意されています。

使い方は単純で、引数として読み込みたいJavaScriptのファイルのパスを渡すだけです。可変長引数的なアレなので、複数のファイルを一気に渡すこともできます。

// background.js内
importScripts("./import.js");

self.onmessage = function(e) {
    self.hogehoge();
    var worker = new Worker("background-2.js");
    worker.postMessage();
};

// background-2.js内
importScripts("./import.js");

self.onmessage = function(e) {
    self.postMessage(self.hogehoge());
};

// import.js内
self.hogehoge = funcion () { return "hoge"; };

HTMLで<script src=”./import.js”>と記述したようなものだと思えばOKです。ただし、importScriptsでは同一生成元ポリシーのルールを守る必要があります。(違反するとNetworkErrorが発生する。)

もしクロスドメインで読み込みたいのであればXHRが使えるのでそれの結果をevalするしかないでしょう。面倒ですし、Access-Control-Allow-Originが設定されてなかったらやっぱり使えないんですが…。

また、importScriptsで読み込むファイルに関してもWeb Workersにおける制約は適用されます。つまり、内部でwindowやdocumentに触っているとエラーになります。(これもNetworkErrorになる。)なのでjQueryは使えません。Ajaxの部分だけ使いたい場合は別のライブラリを探すか自分でXHRで実装することになります。

JSONPもそのままでは使用できません。DOMの操作が出来ないからです。これもXHRで受け取った結果をevalすれば済む話ではありますが…。

ただし、同一生成元ポリシーに違反しない限りはimportScriptsの引数にAPIのURIを渡すことで擬似JSONPを実行できます。

動的にタスクを生成する

Workerのコンストラクタはセキュリティの観点から同一ドメインURIしか受け付けてくれません。しかし、それだと流石に使いにくいです。どうせならFunctionを受け取ってくれればいいのに、と思うので、そうしましょう。

正確には

  1. Functionを基にBlobを作成
  2. Blobをwindow.URL.createObjectURLに食わせてURLを生成
  3. そのURLをWorkerに食わせる

と言う流れになります。

Blobの生成に関してはこの解説が非常にわかりやすかったです。

コンストラクタにはStringの配列、オプションとしてBlobのContent-Typeを指定します。FunctionはtoStirngすることでその定義を取得できるので、タスク内のonmessageで呼び出すようにするだけでOKです。

function createBlob (f) {
    return new Blob(["self.onmessage = " + f.toString() + ";"], { type: "application/javascript" });
}

var blob = createBlob(function(e) { return "hoge"; });
var blobUri = window.URL.createObjectURL(blob);
var worker = new Worker(brobUri);

Blobは使い終わったらwindow.URL.revokeObjectURLで解放してあげるのが親切です。と言うわけで、こんな仕組みがパパッと思いつくと思います。

(function(root) {

    function createBlob (f) {
        return new Blob(["self.onmessage = " + f.toString() + ";"], { type: "application/javascript" });
    }

    function FWorker(handlers) {

        this.task = v.task;
        if (handlers.success) this.success = handlers.success;
        if (handlers.error) this.error = handlers.error;

        this.execute = function (args) {

            // バックグラウンドで実行する内容をBlobに変換する
            var blobUri = window.URL.createObjectURL(createBlob(this.task));
            var worker = new Worker(blobUri);

            if(this.success) {
                var successCallback = this.success;

                worker.onmessage = function(e) {
                    successCallback(e);

                    // 終了後に後片付けを行う
                    window.URL.revokeObjectURL(blobUri);
                    worker.terminate();
                };
            }

            if(this.error) {
                var errorCallback = this.error;
                
                worker.onerror = function(e) {
                    errorCallback(e);
                    
                    // 終了後に後片付けを行う
                    window.URL.revokeObjectURL(blobUri);
                    worker.terminate();
                }
            }

            worker.postMessage(args);
        }

    };

    root.FWorker = FWorker;

})(this);

このままだとimportScriptsが出来ないんですが、その辺はちょっと後回しにします。

Blobに関する互換性問題を解消する

これだけで済むなら割と楽なんですが、Blobの対応ブラウザは結構まちまちです。現在は非推奨となっているBlobBuilderを使用すれば同じことが出来るので、もしBlobに対応していなかったらBlobBuilderを使うようにします。

それともう一つ、どうにもならない問題としてIEではBlobのURIをWorkerのコンストラクタに指定出来ないと言うものがあります。

「WorkerはサポートしているけどBlobはサポートしてない」と言ったクソブラウザのために、「渡されてきたFunctionのStringをevalするだけ」のjsファイルを用意しておきます。

上記の問題はStackOverFlowのこの解説が非常に詳しいので、参考にしてみましょう。ついでにimportScriptsも出来るようにしてしまいます。

(function (root) {

    var _delegator = "";
    var _scripts = [];

    // init
    window.URL = window.URL || window.webkitURL;
    if (!String.prototype.endsWith) {
        Object.defineProperty(String.prototype, 'endsWith', {
            enumerable: false,
            configurable: false,
            writable: false,
            value: function (searchString, position) {
                position = position || this.length;
                position = position - searchString.length;
                return this.lastIndexOf(searchString) === position;
            }
        });
    }

    function FWorker(v) {
        this.task = v.task;
        if (v.success) this.success = v.success;
        if (v.error) this.error = v.error;
        if (v.debug) this.debug = v.debug;

        this.execute = function (args) {

            var isNeedDelegate = false;
            var workerUri;

            // Workerの作成
            if (!window.MSBlobBuilder) {
                // IE以外の場合はblobを作成する
                var blob;
                var scripts = "";
                if (_scripts.length > 0) {
                    scripts = "importScripts('" + _scripts.join("','") + "');\r\n";
                }

                var f = [scripts + "self.onmessage = " + this.task.toString() + ";"];

                try {
                    blob = new Blob(f, { type: "application/javascript" });
                } catch (e) {
                    window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;

                    if (window.BlobBuilder) {
                        blob = new BlobBuilder();
                        blob.append(f);
                        blob = blob.getBlob();
                    }
                }

                if (blob) workerUri = window.URL.createObjectURL(blob, { type: "application/javascript" });
            }

            // IEの場合 or BlobBuilderがなかった場合はdelegatorを使う
            if (!workerUri) {
                isNeedDelegate = true;
                workerUri = _delegator;
            }

            var worker = new Worker(workerUri);

            // Worker.onmessageにイベントをセットする
            if (this.success) {
                var successCallback = this.success;
                var debug = this.debug;
                worker.onmessage = function (event) {
                    if (debug != null && !debug(event)) return;

                    successCallback(event);

                    // 終了後に後片付けを行う
                    if (!isNeedDelegate) window.URL.revokeObjectURL(workerUri);
                    worker.terminate();
                };
            }

            // エラーハンドラ登録
            if (this.error) {
                var errorCallback = this.error;
                worker.onerror = function (event) {
                    errorCallback(event);

                    // 終了後に後片付けを行う
                    if (!isNeedDelegate) window.URL.revokeObjectURL(workerUri);
                    worker.terminate();
                };
            }

            if (!isNeedDelegate) {
                worker.postMessage(args);
            } else {
                var arg = !args ? "" : args;
                worker.postMessage({ func: this.task.toString(), args: arg, scripts: _scripts });
            }

        };
    };

    root.FWorker = FWorker;
    root.FWorker.setDelegator = function (delegator) {
        _delegator = delegator;
    };

    root.FWorker.setImportScripts = function (filePath) {
        _scripts = filePath;
    };

    root.canUseWorker = function () { if (window.Worker) { return true; } else { return false; } }

})(this);

やむを得ず使用するjs(Delegetor)の中身はこんな感じです。そのままevalするとまたスコープが変わってしまい、importScriptsした意味がなくなるので一旦evalを別の変数に受け渡します。

self.onmessage = function (e) {
    try {
        self.onmessage = null; // Clean-up
    } catch (ex) { ; }

    var data = e.data;
    var indirectEval = eval;

    if (data.scripts.length > 0) {
        importScripts(data.scripts.join(","));
    }

    indirectEval("(" + data.func + ")(" + JSON.stringify(data.args) + ")");

};

後はこんな感じに使えばOKです。

FWorker.setDelegator("delegetor.js");

// Blobで実行されているスクリプトはスキーマが「blob:」になってしまうので、
// 相対パスで呼ぶと同一生成元ポリシーに違反してしまう
FWorker.setImportScripts(["http://importscript-1.js"]);

new FWorker({
    task : function(e) {
        self.postMessage({ debug : "debug test." });
        self.postMessage(e.data);
    },
    success : function(e) {
        alert(e.data);
    },
    // Web Workers内のデバッグは非常に面倒くさい
    // 一旦タスク内でデバッグ用の値をpostMessageするのが常套手段となっている
    debug : function(e) {
        // trueを返すとworkerを終了させる
        if(e.data.debug) {
            alert(e.data.debug);
            return false;
        } else {
            return true;
        }
    },
    error: function(e) {
        alert(e.filename + ":" + e.lineno + "\r\n" + e.message);
    }
}).execute("hoge");

まとめ

とまぁ、こんな感じのことを一週間ほどやっていました。とは言え、IE 10とFireFox 14.x以外ではテストしていないんですが…。

Web Workers内でのXMLHttpRequestのノウハウは先人たちが色々やっているので特に紹介しません。私はこのあたりを参考にしました。

ここからは完全に余談になるのですが、上記の仕組みはこのブログでは使っていません。理由は単純で、Tumblrで使うjsは「static.tumblr.com」と言うドメインに置かれてしまうからです。(Delegatorを使ったWorkerを作成したりimportScriptsを呼び出す段階で同一生成元ポリシーに違反してしまう。)

解決策として「static.tumblr.comにWorkerを呼び出すだけのHTMLをアップロードし、iframeで呼び出しておいてpostMessageでやりとりする」と言うものを思いついたんですが、そうなると何でもかんでもevalするFWorkerは巨大なセキュリティホールにしかなりません。

と言うわけで完全に普通のWorkerを使って実装しなきゃいけないので、まだまだ作成中です。そのうち適用します。

[2014/08/29追記]

static.tumblr.comにはHTMLをアップロードできませんでした。完全に万策尽きたので諦めようかと思います…。

参考