Web Workersによるワーカースレッド作成

Webブラウザで実行されるJavaScriptは、通常UIスレッドで実行されます。そのため、JavaScriptで長時間処理を続けるとブラウザが「止まる」わけですね。

従来のJavaScriptでは、長時間処理を続ける場合はsetTimeout()などを駆使して一定時間ごとに分割された処理を繰り返していました。しかし、最近のWebブラウザにはJavaScriptからワーカースレッドを生成し、その中で処理を行うWeb Workersという仕組みが実装されています。

Web Workersは、別のjsファイルで記述したJavaScriptに対してメッセージを送る形でデータを渡し、イベントハンドラを起点にワーカースレッドを起動します。このスレッドはWebブラウザのDOMやUIを操作するUI(メイン)スレッドとは別スレッドとして起動され、WebブラウザのUI更新を妨げずに処理を続けることが可能です。

具体的には、jsファイルを指定してWorkerオブジェクトを作成し、postMessage()でデータを渡してワーカースレッドを起動します。

var worker = new Worker('test.js');

worker.postMessage(param);

Web Workerで実行する処理を記述するjsファイルの側では、「引数に渡されたデータを使って処理を行うイベントハンドラ」内で具体的な処理を実行するわけです。

// メッセージを受け取ったら処理関数を呼び出す
addEventListener('message', function(e) { process(e.data); }, false);

function process(param) {
	・・・
}

上のようにしておくと、メインスレッドでworker.postMessage(param)とすると、Web Workerの「message」イベントハンドラが呼び出され、その中から処理を実行する関数process()が呼び出される、という流れになります。

処理が終わったら、Web Workerの関数からpostMessage()を実行するとメイン側にメッセージが送られるので、メイン側ではその結果を使って処理を行うイベントリスナを登録しておきます。

ただし、Web Workersのスレッド内ではDOM(WebブラウザのHTMLツリー)に対する操作はできません。また、データのやり取りも「値(コピー)渡しの引数」を通して行う形になるので、データ(オブジェクト)を直接共有することもできません。
メインのJavaScriptとは、「別世界」で動く感じですね。

今回は、こうしたWeb Workersの特殊性を確かめる意味で「やや大きな計算データをやり取りして計算を繰り返した後、Canvasに描画」するプログラムを作ってみましょう。計算は、ある程度「時間のかかる処理」が望ましいですね。そう、たとえばマンデルブロ集合のような。

まず、Web Workersでワーカースレッドを作る前に「スレッドで行いたい処理をまとめた関数」を作っておきましょう。データに関しては、とりあえずObjectを一つ作ってその中(プロパティ)としてマンデルブロ集合の座標や計算結果(の画像を格納するフレームバッファ)を入れて、関数に渡すことにします。

// 描画用ImageData
var webworkers_mm_imageData;

// 色設定
var webworkers_mm_color = new Object();

// マンデルブロ集合内
webworkers_mm_color.inR = 'i / 3';
webworkers_mm_color.inG = '0';
webworkers_mm_color.inB = '255 - (j / 2)';

// マンデルブロ集合外
webworkers_mm_color.outR = 'n * 3';
webworkers_mm_color.outG = 'n * 4';
webworkers_mm_color.outB = 'n * 2';

var webworkers_mm_context = document.getElementById('webworkers_mm_screen').getContext('2d');

function webworkers_mm_start() {

	// Canvas要素から描画用ImageDataを取得
	webworkers_mm_imageData = webworkers_mm_context.createImageData(512, 512);

	// 関数に渡すパラメーターを格納するObject
	var param = new Object();

	// マンデルブロ集合の描画範囲
	param.sx = -1.5;
	param.sy = -1.0;
	param.rangeX = 2.0;
	param.rangeY = 2.0;

	// マンデルブロ集合の描画サイズ
	param.width = 512;
	param.height = 512;

	// 色設定
	param.colorSet = webworkers_mm_color;

	// ImageData
	param.buf = webworkers_mm_imageData.data;

	// 最大の計算回数
	param.maxN = 400;

	// 計算実行
	webworkers_mm_calc(param);

	// ImageDataの画像データをCanvas要素に転送
	webworkers_mm_context.putImageData(webworkers_mm_imageData, 0, 0);

}

function webworkers_mm_calc(data) {

	// 計算開始位置
	var sx = data.sx;
	var sy = data.sy;

	// 計算範囲
	var rangeX = data.rangeX;
	var rangeY = data.rangeY;

	// 描画範囲
	var width = data.width;
	var height = data.height;

	// 最大計算回数
	var maxN = data.maxN;

	// 色設定
	var colorSet = data.colorSet;

	// 1ピクセル刻み幅
	var stepX = rangeX / width;
	var stepY = rangeY / height;

	// 計算結果格納バッファ(int配列)
	var buf = data.buf;

	var i, j, k;
	var n;

	var tr, ti, vr, vi, pr, pi;
	var index;

	var r, g, b;

	for (i = 0;i < height;i++) {
		for (j = 0;j < width;j++) {

			n = 0;

			vr = 0;
			vi = 0;

			// 複素平面上ピクセル位置の実部/虚部の値を算出
			pr = sx + (stepX * j);
			pi = sy + (stepY * i);

			// ピクセル位置における発散回数を計算
			do  {

				tr = (vr * vr) - (vi * vi) + pr;
				ti = (vr * vi * 2.0) + pi;

				vr = tr;
				vi = ti;

			} while ((((vr * vr) + (vi * vi)) < 4 && (++n < maxN)));

			// ImageDataのオフセット算出
			index = (j * 4) + (i * width * 4);

			// 発散回数に応じてピクセルの色を設定
			if (n == maxN) {

				r = eval(colorSet.inR);
				g = eval(colorSet.inG);
				b = eval(colorSet.inB);

			} else {

				r = eval(colorSet.outR);
				g = eval(colorSet.outG);
				b = eval(colorSet.outB);

			}

			// R要素を0-255に収める
			if (r < 0) {
				r = 0;
			}

			// R要素を0-255に収める
			if (r > 255) {
				r = 255;
			}

			// G要素を0-255に収める
			if (g < 0) {
				g = 0;
			}

			// G要素を0-255に収める
			if (g > 255) {
				g = 255;
			}

			// B要素を0-255に収める
			if (b < 0) {
				b = 0;
			}

			// B要素を0-255に収める
			if (b > 255) {
				b = 255;
			}

			buf[index] = r;
			buf[index + 1] = g;
			buf[index + 2] = b;

			// 不透明度を255に設定
			buf[index + 3] = 255;

		}
	}

}

メインスレッドの関数ですので、とりあえずマンデルブロ集合を描くImageDataは共有(参照として渡す)してしまいましょう。HTMLファイルには、マンデルブロ集合を描くためのcanvas要素(webworkers_mm_screen)を配置し、クリックするとwebworkers_mm_start()を呼び出して描画を行うボタンも入れておきます。

また、ブラウザの動作を確認するためにsetTimeout()30msごとに画面表示を行う関数を動かし、その中で「前回の呼び出しからの経過時間」「呼び出し回数」を表示してみました。メインスレッドが止まると呼び出し回数の更新が停止し、「重い」処理で呼び出しが遅延すると、上の方にあるグラフの赤い部分が伸びていきます。

UIスレッドでマンデルブロ集合を描く

ボタンを押すと、しばらくWebブラウザの行進が止まってその後マンデルブロ集合が描かれましたね。

続いて、Web Workersで描画してみます。関数の基本構造は同じですが、引数で渡される変数を書き換えてもメインスレッドの変数には反映されないので、「描画結果のデータ」をメインスレッドに対するメッセージの引数として返すことにします。

描画データは、ImageDataのdataプロパティに合わせてUint8Array型(型付配列)にしておきましょう。この配列に、マンデルブロ集合の計算結果に応じた画像データを作成し、メインスレッドに渡すメッセージの引数とします。

Web Workersのスレッド関数を記述するjsファイルは、以下のようになります。

// メッセージを受け取ったら処理関数を呼び出す
addEventListener('message', function(e) { webworkers_mm_calc(e.data); }, false);

function webworkers_mm_calc(data) {

	・・・

	// 計算結果格納バッファ
	var buf = new Uint8Array(width * height * 4);

	・・・

	// 処理結果をメインスレッドに渡す
	postMessage(buf);

}

Web Workerから受け取ったデータは、メイン側のメッセージ受信関数でcanvas要素に反映させてWebブラウザに表示します。

・・・

var worker = new Worker('webworkers_mm2.js');

// 結果を受け取るイベントハンドラ設定
worker.addEventListener('message', function(e) { webworkers_mm_onWorkerMessage(e); }, false);

// Web Workerの処理開始
worker.postMessage(param);

・・・

function webworkers_mm_onWorkerMessage(e) {

	var i;

	// 受け取ったデータをImageDataにコピー
	for (i = 0;i < e.data.length;i++) {
		webworkers_mm_imageData.data[i] = e.data[i];
	}

	// ImageDataの画像データをCanvas要素に転送
	webworkers_mm_context.putImageData(webworkers_mm_imageData, 0, 0);

}

これで、マンデルブロ集合の計算を「別スレッドで処理」することができるようになりました。

Web Workerでマンデルブロ集合の計算を実行く

今度は、計算中もWebブラウザの描画処理が動き続けますね。処理速度については、私の環境(Core 2DuoとWindows Vista/Firefox 25)ではWeb Workerで実行した場合も特に大きな低下は見られないようです。


創作プログラミングの街