俺のWeb Workersがこんなに遅いわけがない
フロントエンドエンジニアのらくさんです。ソニックムーブ Advent Calendar 2013 2日目の記事になります。
HTML5のAPIにWeb Workersというものがありますが、Androidの標準ブラウザでは使えないためスマートフォン向けには使いにくい状況が続いていました。しかし、Android 4.4 KitKatではWebViewがChromium 30ベースのものになり、標準ブラウザはどうなるのかまだ不透明ではありますが、標準ブラウザとしてChromeをプリインストールするかChromiumベースのものになっていく流れだと思われます。
そのため、スマートフォン向けの開発でも今後はWeb Workersが使える機会が徐々に増えていくでしょう。私が今開発しているスマートフォン向けのサービスでもWeb Workersを使っています。そこでこの記事では、Web Workersについて簡単におさらいし、Web Workersとのデータ受け渡しを高速化するTransferable Objectsについてまとめました。
Web Workersのおさらい
まずはWeb Workersについて簡単におさらいをします(Web Workersって何だっけ?という人以外は読み飛ばしてもらって構いません)。
JavaScriptという言語は、すべての処理が単一スレッドで動作するようにできています。setTimeoutで実行される処理やXMLHttpRequestのコールバックなど、非同期で動作するものはたくさんありますが、これらも全て単一のスレッドで動作します。ですからsetTimeoutで実行される処理に時間がかかりすぎると、その間は他の処理が全て止まってしまいブラウザが固まったりするわけです。そんなJavaScriptにマルチスレッドプログラミングをもたらすのがWebWorkersです。
1 2 3 4 5 6 7 8 |
[sourcecode lang="javascript"] var data = "HELLO WORKER WORLD!"; var worker = new Worker("worker1.js"); worker.onmessage = function(event) { alert(event.data); }; worker.postMessage(data); [/sourcecode] |
2行目で worker1.js というJavaScriptファイルを実行するワーカーを作り、3行目でそのワーカーからデータを受け取るイベントハンドラを設定、6行目でワーカーにメッセージを送信しています。
worker1.js は次のようになります。受け取ったメッセージを小文字にして送り返しているだけです。
1 2 3 4 5 6 |
[sourcecode lang="javascript"] self.onmessage = function(event) { var lower = event.data.toLowerCase(); self.postMessage(lower); }; [/sourcecode] |
実行すると「hello worker world!」と小文字でアラートが表示されます。[実行する]
メインスクリプトとワーカーの間のデータのやり取りは postMessage 関数を介して行う必要があります。この例ではメインスクリプトのグローバルスコープに data という変数がありますが、ワーカー側のスクリプトから data にアクセスすることはできません。
1 2 3 4 5 6 |
[sourcecode lang="javascript" highlight="2"] self.onmessage = function(event) { var lower = data.toLowerCase(); // ReferenceError: Can't find variable: data self.postMessage(lower); }; [/sourcecode] |
また、ワーカー内ではDOMの操作はできません。他にもできないことがいくつかありますが、ここではそれらについて触れません。各自調べてください。
参考資料
http://www.slideshare.net/kaboccha/web-workers-16750637
https://developer.mozilla.org/ja/docs/Web/Guide/Performance/Using_web_workers
目次
大きなデータを受け渡す
メインスクリプトとワーカーの間でデータを受け渡す際、オブジェクトの参照が渡されて共有されるのではなく、コピーされて渡されます。さきほどの例で受け渡されるデータはとても小さく、データがコピーされることによるオーバーヘッドを気にするようなものではありません。しかし、もっと大きなデータを受け渡したい場合はどうでしょうか。
次のコードは、32MBのUint8Arrayを作りメインスクリプトとワーカーの間で1往復の受け渡しをし、その時間を計測しています。(始めに”ready”というメッセージを受け渡しているのは、ワーカーの初期化が完了したことを確認してから計測を行うためです)
メインスクリプト
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[sourcecode lang="javascript"] var data = new Uint8Array(32*1000*1000); var worker = new Worker("worker2.js"); var startTime; worker.onmessage = function(event) { if (event.data === "ready") { startTime = Date.now(); worker.postMessage(data); } else { alert((Date.now() - startTime) + " ms"); } }; worker.postMessage("ready"); [/sourcecode] |
worker2.js
1 2 3 4 5 |
[sourcecode lang="javascript"] self.onmessage = function(event) { self.postMessage(event.data); }; [/sourcecode] |
私のiPhone5sでは150ms前後、iPhone5(iOS6)では200ms前後でした。[実行する]
Transferable Objects
150ms程度だと、一度だけワーカーに渡せばよい場合はそれほど問題ではないかもしれませんが、繰り返し受け渡す場合は十分な速さとはいえません。そこで、コードを次のように修正します。
メインスクリプト
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[sourcecode lang="javascript" highlight="7"] var data = new Uint8Array(32*1000*1000); var worker = new Worker("worker3.js"); var startTime; worker.onmessage = function(event) { if (event.data === "ready") { startTime = Date.now(); worker.postMessage(data, [data.buffer]); } else { alert((Date.now() - startTime) + " ms"); } }; worker.postMessage("ready"); [/sourcecode] |
worker3.js
1 2 3 4 5 6 7 8 9 10 |
[sourcecode lang="javascript" highlight="6"] self.onmessage = function(event) { var data = event.data; if (data === "ready") { self.postMessage(data); } else { self.postMessage(data, [data.buffer]); } }; [/sourcecode] |
メインスクリプト、ワーカーとも postMessage に2つ目の引数を追加しています。この修正により2ms程度になりました。[実行する]
この postMessage の2つ目の引数が、今回の記事のメインテーマです。Workerの仕様を見ると、postMessage は次のように書かれており、2つ目の引数にTransferableの配列を渡すことができることがわかります。
void postMessage(any message, optional sequence<Transferable> transfer);
Transferableの仕様はここに書かれています。Transferableなオブジェクトであれば、postMessage の2つ目の引数に列挙することで、コピーを伴わずに転送することができます。
Transferableなオブジェクトは、今のところ ArrayBuffer と MessagePort のふたつだけです。ですから、なんでもコピー無しで転送できるわけではありません。先ほどのコードでは Uint8Array をワーカーに渡しました。Uint8Array は buffer プロパティに ArrayBuffer を持ち、これをデータ領域としていますから Uint8Array のバッファである ArrayBuffer はコピー無しで転送できるのです(したがって Uint8Array 自身はコピーされます)。
複数のArrayBufferを転送する
1 2 3 4 5 6 7 |
[sourcecode lang="javascript"] var data = { name: "data 1", ui8: new Uint8Array(size1), f32: new Float32Array(size1) }; [/sourcecode] |
このようなオブジェクトがあった場合、次のようにすることで、ui8 と f32 が持つ ArrayBuffer はコピー無しで転送できます。Uint8Array 自身と Float32Array 自身、それと name の文字列はコピーされます。
1 2 3 |
[sourcecode lang="javascript"] worker.postMessage(data, [data.ui8.buffer, data.f32.buffer]); [/sourcecode] |
CanvasのImageData
CanvasのImageDataのデータ領域もArrayBufferのため、コピー無しでワーカーに転送できます。大量のピクセル操作を行う場合にとても有用です。
1 2 3 4 5 |
[sourcecode lang="javascript"] var context = canvas.getContext("2d"); var imageData = context.getImageData(0, 0, canvas.width, canvas.height); worker.postMessage(imageData, [imageData.data.buffer]); [/sourcecode] |
ちなみに、CanvasとWorker関連ではCanvasProxyという仕様が策定中です。ImageDataの転送ではピクセル単位の操作しかできませんが、CanvasProxyによりWorker内でCanvasの機能が使えるようになります。
1 2 3 4 |
[sourcecode lang="javascript"] var proxy = canvas.transferControlToProxy(); worker.postMessage(proxy, [proxy]); [/sourcecode] |
ワーカー側
1 2 3 4 5 6 7 |
[sourcecode lang="javascript"] self.onmessage = function(event) { var context = new CanvasRenderingContext2D(); event.data.setContext(context); context.fillStyle = "red"; context.fillRect(...); };[/sourcecode] |
転送後のTransferableなオブジェクトは転送元でどうなる?
転送後のTransferableなオブジェクトは転送元ではアクセスできなくなります。
1 2 3 4 5 6 |
[sourcecode lang="javascript"] var data = new Uint8Array(32*1000*1000); console.log("before: " + data.length + ", " + data.buffer); worker.postMessage(data, [data.buffer]); console.log("after: " + data.length + ", " + data.buffer); [/sourcecode] |
コンソール
1 2 3 4 |
[sourcecode lang="text"] before: 32000000, [object ArrayBuffer] after: 0, null [/sourcecode] |
(Safariではdata.bufferは転送後にnullになりますが、Chromeではnullになりませんでした。しかし、data.lengthは0になっており、データの内容にはアクセスできないことに変わりありません。)
Transferable Objectsを利用できる環境かどうかを判定する
Transferable Objectsを利用できない環境でpostMessageの2つ目の引数を指定してもコピーを伴って送信されます。その場合、前述のdata.lengthは0にならずにもとの長さのままです。これを利用してTransferable Objectsを利用できる環境かどうか判定することができます。
1 2 3 4 5 6 7 8 9 |
[sourcecode lang="javascript"] var data = new Uint8Array(1); worker.postMessage(data, [data.buffer]); if (data.length === 0) { // Transferable Objectsを利用できる } else { // Transferable Objectsを利用できない } [/sourcecode] |
まとめ
この記事では、Transferable Objectsを使ってワーカーとのデータ受け渡しを高速化する方法をまとめました。現状では、TransferableであるのはArrayBuffer(とMessagePort)だけなので使用範囲は限定されますが、大きなデータを繰り返し受け渡すような場合には、Transferable Objectsを使えるようにデータ構造を設計することで大きな効果が期待できます。また、冒頭で述べたように今後はスマートフォンでもWeb Workersを利用できる環境が整って行くと思われるので、端末側で複雑な処理を行う必要がある場合には、Web Workersの利用を検討してみると良いでしょう。