Subterranean Flower

Web Workersを用いてJavaScriptをマルチスレッド化する

Author
古都こと
ふよんとえんよぷよぐやま!!!

長らくの間、JavaScriptはシングルスレッドな言語でした。重い処理を実行するたびにUI処理はブロックされ、マルチコアCPUは上手に使えない、それがJavaScriptの常識でした。ですが、Web Workersがあればそれが変わります。

Web Workersとは

JavaScriptは、Workerという処理単位を持ちます。Workerはバックグラウンドで非同期に処理を行うための最も簡単な手法であり、JavaScriptに他の処理を妨げないタスクを実行する手段をもたらします。

しかしなぜWorkerという名前なのでしょうか。プログラマにとってなじみ深い「スレッド」ではいけなかったのでしょうか。

独立したメモリ空間

事実、Workerはスレッドのようなものと言えます。しかしながら、Workerにはプログラマが一般的に想像するスレッドとは少し違う点があります。スレッドというよりかは、どちらかというと「プロセス」に近いかもしれません。

Workerはそれぞれ異なる独立したメモリ空間を持ちます。簡単に言うと、複数のWorkerから同じ変数にアクセスすることはできないということです。Worker AとWorker Bがあった場合、Worker AはWorker Bの値にアクセスできませんし、逆も同じです。同じ値に同時にアクセスできない!まさか!では、いったいどうやって協調動作するというのでしょうか。

メッセージング

同じ変数にアクセスできないのは困ったことです。なぜならばマルチスレッド(ここではマルチWorkerという方が正しいでしょうか)処理では、複数のスレッドが協調動作することによって高速な動作を実現することができるからです。しかし、Workerにはそのための仕組みがありません。

かわりに、Workerにはメッセージングという仕組みがあります。名前の通り、互いにメッセージを送りあう仕組みです。ここでメッセージというのは、いわゆるオブジェクトのことです。オブジェクトを送りあうことで、またオブジェクトを送りあうことのみで、Workerは協調動作を実現します。

常にスレッドセーフ

独立したメモリ空間とメッセージング。Workerはなぜ一般的なスレッドのように振舞わず、このような回りくどい方法をとるのでしょうか。

この面倒な方法には大きな利点があります。それは常にスレッドセーフだということです。そもそもお互いの変数にアクセスすることができないのですから、スレッドセーフかどうかを気にする必要がありません。Workerがこの仕組みを採用したことで、常に安全なマルチスレッドプログラムを書くことができるのです。

しかしここから次のことも言えます:Workerはスレッドセーフではない機能は使えない。詳しくは後ほど説明しますが、DOMやLocal Storageがそれにあたります。

(2017/10/31日追記)SharedArrayBuffer

Worker間で値を共有するための新しい仕組みとして、SharedArrayBufferが導入されました。詳しくは「SharedArrayBufferとAtomics APIを用いてWorker間でデータを共有する」をご覧ください。

Web Workerの使い方

Workerの作成

Workerの作成方法は実に簡単です。Workerオブジェクトをnewするだけで作ることができます。

// Workerを生成する
const worker = new Worker('worker.js');

このとき、生成するWorkerの処理は別のファイル(ここではworker.js)に書いておく必要があります。

メッセージの送信と受信

Workerを生成したらメッセージを送信しましょう。メッセージの送信にはWorkerオブジェクトのpostMessageメソッドを使用します。送信可能なメッセージはJSONオブジェクトのみです。

// Workerを作成する
const worker = new Worker('worker.js');

// メッセージを送信する
worker.postMessage('こんにちは!');

しかしこれだけでは何も起こりません。Worker側の処理を書いていないから当然です。次はworker.jsファイルを編集してWorker側の処理を書きましょう。

// メッセージを受信してコンソールに表示する
self.addEventListener('message', (message) => {
    console.log(message.data);
});

Worker内でグローバルスコープを取得するには、selfを使います。また、メッセージの受信はmessageイベントで行います。ここでは受信したメッセージの内容をコンソールに出力するだけのコードを書いています。

これを実行するとコンソールに「こんにちは!」と表示されます。メッセージの送受信がうまくいった証拠です。

また、Worker側からもメッセージを送信することができます。Worker側からメッセージを送信するには、同じくpostMessageメソッドを使用します。

// Workerを作成する
const worker = new Worker('worker.js');

// メッセージを受信してコンソールに表示する
worker.addEventListener('message', (message) => {
   console.log(message.data);
});

// メッセージを送信する
const myName = '古都こと';
worker.postMessage(myName);
// メッセージを受信してメッセージを送り返す
self.addEventListener('message', (message) => {
    const name = message.data;
    self.postMessage(`こんにちは、${name}さん!`);
});

これを実行すると「こんにちは、古都ことさん!」と表示されます。

エラー処理

エラーを処理するにはerrorイベントを使用します。

// Workerを作成する
const worker = new Worker('worker.js');

// エラーを表示する
worker.addEventListener('error', (error) => {
   console.log(error);
});

// メッセージを送信する
worker.postMessage('');
// エラーを発生させます
self.addEventListener('message', (message) => {
    const value = 1 + undefinedVariable;
});

Workerの終了

Workerを終了させるにはterminateメソッドを呼び出します。

worker.terminate();

あるいはWorker側でcloseメソッドを呼び出します。

self.close();

Transferableオブジェクト

Workerのメッセージ送信は「コピー」です。感のいい方は気づいたかもしれませんが、大きなメッセージを送信すると、その分だけ時間がかかります。これは大きな問題です。巨大な処理をするためにWorkerを起動するのに、その巨大なオブジェクトのコピーを作るのに結局時間がかかってしまうというのは、本末転倒です。

そこでJavaScriptではTransferableオブジェクトというものがあります。これは名前の通り、コピーではなく「転送」することができるオブジェクトです。Tranferableオブジェクトを利用することで、短い時間でメッセージを送信することができます。

Transferableオブジェクトは、現在のところArrayBufferMessagePortだけです。最も身近なArrayBufferはTypedArrayオブジェクトbufferプロパティでしょう。これを送信する例を次に示してみます:

const data = new Uint8Array(1024);
worker.postMessage(data.buffer, [data.buffer]);

ここでのポイントは、postMessageメソッドのふたつめの引数に、Transferableオブジェクトを配列で渡すことです。こうすることで、値がコピーされずに転送されるようになり、メッセージ送信が高速で終わります。

Workerの制限

前述した通り、Workerにはいくつかの制限があります。Worker上において、次のオブジェクトはアクセス可能です:

  • navigator
  • location
  • XHR

しかし次のオブジェクトにはアクセスできません:

  • window
  • document
  • DOM

制限に気をつけつつ、可能な範囲でWorkerを活用しましょう。

Web Workersの活用

重い処理の委譲

Workerはメインスレッドをブロックしません。よって、重い処理を実行させても「スクリプトの応答がありません」などとダイアログが表示されることもありません。これを利用して重い処理をWorkerにやらせるという手法をとることができます。

例えば素数判定です。高速化せずに素朴にコードを書くとなかなか重い処理になります。これをWorkerにやらせてみましょう。

// Workerを作成する
const worker = new Worker('worker.js');

// メッセージを受信する
// 素数だったならば「素数です!」
// そうでなければ「素数ではありません…」
// と表示します。
worker.addEventListener('message', (message) => {
    const isPrime = message.data;
    const str = isPrime ? '素数です!' : '素数ではありません…';
    console.log(str);
});

// メッセージを送信する
worker.postMessage(2038074743);

まずはWorkerに数値を送信します。そしてWorkerからbool値で判定を返してもらい、素数かどうかをコンソールに表示します。2038074743というのはなかなか大きな素数で、判定には時間がかかるはずです。

次にworker.js側を見てみましょう。

// 素数かどうかを判定します
// 処理を遅くするため、あえて一切の高速化をしていません
function isPrimeNumber(n) {
    for(let i=2; i < n; i++) {
        if(n % i == 0) { return false; }
    }

    return true;
}

// メッセージ(数値)を受け取ったら素数がどうかを判定し、
// 結果をメッセージとして送信します。
self.addEventListener('message', (message) => {
    const isPrime = isPrimeNumber(message.data);
    self.postMessage(isPrime);
});

worker.js側では受け取った数値を素数かどうか判定し、判定をbool値で送信します。このとき、WorkerがUIスレッドをブロックしないことを確認するため、できるだけ時間がかかるようにしたいので、あえて高速化しない実装にしてあります。

これを実行すると、しばらく時間が経ったのち、「素数です!」と表示されます。実行に時間はかかりますが、スクリプトの実行中止を促すダイアログは表示されません。Workerを活用することで、UIスレッドを妨げることなく重い処理を実行することができます。

処理を高速化する

Workerの他の活用方法として、処理を高速化するというものがあります。Workerは複数生成することができるので、うまく使えば重い処理を高速化することができます。

例えば先ほどの素数判定を考えてみましょう。素数判定は2から順番に数値で割れるか確認していくだけなので、簡単に複数のジョブに分割することができます。それぞれのジョブをWorkerに割り振れば、マルチスレッドで高速な処理が実現できるはずです。

まずはメインファイルを書きましょう。

// CPUコア数*2 程度がおおまかな高速化の限度。
// 4コアCPUなら4〜8にしておく
const MAX_WORKERS = 4;

// 判定に使う素数。ある程度大きくないと
// 高速化できているのがわからない
const num = 2038074743;

// Workerひとつにつき、いくつの数字を処理するかの数
const chunkSize = Math.ceil(num / MAX_WORKERS);

// 分割したジョブを格納する配列
const jobs = [];

// 時間を計測する
console.time('primeNumber');

// 各Workerにジョブを割り振る
for(let i = 0; i < MAX_WORKERS; i++) {
    const worker = new Worker('worker.js');
    const from = Math.max(chunkSize * i, 2);
    const to = Math.min(chunkSize * (i + 1), num);

    const promise = new Promise((resolve, reject) => {
       worker.addEventListener('message', (message) => {
           resolve(message.data);
       });
    });

    jobs.push(promise);
    worker.postMessage({num: num, from: from, to: to});
}

// すべてのジョブが終了したら結果を集計して
// 素数かどうかの判定を出す
Promise.all(jobs).then((results) => {
    const isPrime = results.every((r) => r);
    console.log(isPrime ? '素数です!' : '素数ではありません…');
    console.timeEnd('primeNumber');
});

メインファイルでの処理は簡単です。数字をジョブ数で分割して、各Workerに割り当てます。すべてのWorkerの処理が終了したら、結果を表示します。

次にworker.jsを見てみましょう。こちらはほとんど変更点はありません。

// 素数かどうかを判定します
// 処理を遅くするため、あえて一切の高速化をしていません
function isPrimeNumber(n, from, to) {
    for(let i=from; i < to; i++) {
        if(n % i == 0) { return false; }
    }

    return true;
}

// メッセージ(数値)を受け取ったら素数がどうかを判定し、
// 結果をメッセージとして送信します。
self.addEventListener('message', (message) => {
    const num = message.data.num;
    const from = message.data.from;
    const to = message.data.to;
    const isPrime = isPrimeNumber(num, from, to);
    self.postMessage(isPrime);
});

変更点はisPrimeNumber関数が処理範囲を引数として受け取るようになっただけです。

これを実行してみましょう。計算結果と計算時間が表示されるはずです。MAX_WORKERSの値を色々変えて試してみましょう。私の場合、MAX_WORKERS = 1のときは12030ms、4のときは4541ms、8のときは3685msとなり、Worker数に応じた高速化が確認できます。(※CPUコア数を超えたWorker数でも高速化される原理については「ハイパースレッディング」で検索してください)

まとめ

Web Workersを使用することで、JavaScriptにおいてもマルチスレッド処理を実現することができました。いくつかの制限や使用方法のコツなどはありますが、シンプルで使いやすい作りになっています。メモリは各Workerで独立しており、協調動作はメッセージングによって行われます。

Workerを使用することで裏で重い処理をすることができたり、並列処理による高速化が実現できます。適切に使用することができれば、心強い味方になるでしょう。