JavaScript は基本的にシングルスレッドであり、並列処理を行うことはできない。
そのため何か重たい処理があると、それによってメインスレッドが専有されてしまい、後続の処理が遅延してしまう。
その結果、ウェブアプリの速度や操作性に悪影響を与えてしまう可能性がある。
Web Worker を使うとマルチスレッドによる処理が可能になり、重たい処理をメインスレッドではなく他のスレッドに処理させることができる。
この記事では、Web Worker の基本的な使い方を見ていく。
動作確認は以下の環境で行った。
- Deno 1.9.0
- Google Chrome 90.0.4430.72
ワーカースレッドの作成
Web Worker には専用ワーカー(Dedicated Worker)と共有ワーカー(Shared Worker)がある。
この記事では Shared Worker については扱わず、Dedicated Worker に絞って話を進めていく。以降、単に Worker と書いた際は Dedicated Worker を指す。
早速 Worker を使っていきたいのだが、まずは動作確認のためのサーバを立てる。
以下の内容のserver.js
を書き、$ deno run --unstable --allow-net --allow-read server.js
を実行する。
このコードは重要ではなく、HTML ファイルと JavaScript ファイルを返すサーバでさえあればよいので、読む必要はない。
const html = ` <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Web Worker</title> </head> <body> <script src="/main.js"></script> </body> </html> `; for await (const conn of Deno.listen({ port: 8080 })) { handleConn(conn); } async function handleConn(conn) { const httpConn = Deno.serveHttp(conn); for await (const { request, respondWith } of httpConn) { switch (true) { case /^\/$/.test(request.url): { respondWith( new Response(html, { status: 200, headers: { "Content-Type": "text/html" }, }) ); break; } case /\.js$/.test(request.url): { const jsFile = await Deno.readFile(`.${request.url}`); respondWith( new Response(jsFile, { status: 200, headers: { "Content-Type": "text/javascript" }, }) ); break; } default: { respondWith( new Response("Not Found\n", { status: 404, headers: { "Content-Type": "text/plain" }, }) ); break; } } } }
次に、main.js
を書く。このファイルが HTML ファイルから読み込まれることになる。
const worker = new Worker("worker.js"); console.log(worker);
そして、worker.js
というファイルを作成する。中身はまだ空でよい。
この状態でhttp://localhost:8080/
にアクセスすると、ブラウザのログに以下の内容が流れる。
Worker {onmessage: null, onerror: null}
これで、ワーカースレッドを作成できたことになる。
このようにnew Worker(ファイル名)
という形でWorker
のインスタンスを作成するところから、Dedicated Worker の利用は始まる。
この例の場合、main.js
に書かれた内容がメインスレッドで実行され、worker.js
で書かれた内容がワーカースレッドで実行される。
Worker として呼び出せるのは、ウェブページと同じオリジンのファイルのみ。
あくまでもウェブページのオリジンが基準であり、呼び出し元の JavaScript ファイル(この例だとmain.js
)のオリジンと一致するかは、問われない。
例えば以下のケースではウェブページとworker.js
が同じオリジン(http://localhost:8080
)であるため、エラーにならない。
<!-- http://localhost:8080/ --> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Web Worker</title> </head> <body> <script src="http://localhost:8081/main.js"></script> </body> </html>
// http://localhost:8081/main.js const worker = new Worker("http://localhost:8080/worker.js"); console.log(worker);
Worker のコンテキスト
ワーカースレッドで実行されるコードは、メインスレッドで実行される場合とは全く異なるコンテキストで動く。
// main.js const worker = new Worker("worker.js"); console.log(self); // Window console.log(Object.getPrototypeOf(self)); // Window console.log(Object.getPrototypeOf(Object.getPrototypeOf(self))); // WindowProperties
// worker.js console.log(self); // DedicatedWorkerGlobalScope console.log(Object.getPrototypeOf(self)); // DedicatedWorkerGlobalScope console.log(Object.getPrototypeOf(Object.getPrototypeOf(self))); // WorkerGlobalScope
ワーカースレッドのグローバルスコープにはWindow
オブジェクトは存在せず、利用できるメソッドが異なる。例えば DOM API は利用できない。
主要なメソッドとイベント
メインスレッドとワーカースレッドはお互いにメッセージを送受信することによって、連携していく。
まずはメインスレッドからの送信。
これは簡単で、Worker
インスタンスのpostMessage
メソッドを使えばよい。
// main.js const worker = new Worker("worker.js"); worker.postMessage("First message.");
そしてメッセージを送信すると、ワーカースレッド側でmessage
イベントが発生する。
そのため、message
イベントにイベントハンドラを設定しておくことで、受信時の処理を設定できる。
受信したメッセージはイベントオブジェクトのdata
プロパティに入っている。
// worker.js self.addEventListener( "message", (e) => { console.log(e.data); // First message. }, false );
逆にワーカースレッドからメインスレッドにメッセージを送る際も、postMessage
メソッドとmessage
イベントを使う。
// main.js const worker = new Worker("worker.js"); worker.addEventListener( "message", (e) => { console.log(e.data); }, false ); worker.postMessage("First message."); worker.postMessage("ABC"); // 以下のログが流れる // 1 回目に受け取ったデータ => First message. // 2 回目に受け取ったデータ => ABC
// worker.js let counter = 0; self.addEventListener( "message", (e) => { counter += 1; self.postMessage(`${counter} 回目に受け取ったデータ => ${e.data}`); }, false );
メインスレッドではWorker
インスタンス、ワーカースレッドではself
オブジェクト(グローバルオブジェクト)、という違いはあるが、どちらでもpostMessage
メソッドとmessage
イベントが存在するので、それを使ってメッセージの送受信を行うのが基本になる。
メッセージで渡される値はコピーされたものであり、値を共有しているわけではない。
オブジェクトを渡すときは一旦シリアライズして送信され、それをデシリアライズしている。
そのためだと思われるが、リテラルやオブジェクトは渡せるが、関数を渡そうとするとエラーになる。
const worker = new Worker("worker.js"); worker.postMessage(1); // ok worker.postMessage(true); // ok worker.postMessage({ a: "b" }); // ok worker.postMessage(() => {}); // Uncaught DOMException: Failed to execute 'postMessage' on 'Worker': () => {} could not be cloned.
message
イベントの他にerror
イベントもあり、ワーカースレッドで例外が投げられると発生する。
これにイベントハンドラを設定しておくことで、発生したエラーの詳細を知ることができる。
const worker = new Worker("worker.js"); worker.addEventListener( "message", (e) => { console.log(e.data); }, false ); worker.addEventListener( "error", (e) => { console.log(e.filename); // http://localhost:8080/worker.js console.log(e.lineno); // 9 console.log(e.colno); // 1 console.log(e.message); // Uncaught ReferenceError: x is not defined }, false ); worker.postMessage("a");
self.addEventListener( "message", (e) => { self.postMessage(e.data); }, false ); x;
Worker
インスタンスのterminate
メソッドを使うと、ワーカースレッドを終了させることができる。
以下のコードでは、最初のメッセージが返ってきた時点でワーカースレッドが終了するので、ログにはa
のみが表示される。
const worker = new Worker("worker.js"); worker.addEventListener( "message", (e) => { console.log(e.data); worker.terminate(); }, false ); worker.postMessage("a"); worker.postMessage("b");
self.addEventListener( "message", (e) => { self.postMessage(e.data); }, false );
ウェブアプリケーションをマルチスレッドで処理する
基本的な使い方は分かったので次は、利用例を示していく。
ワーカースレッドを使うことで、メインスレッドが特定の処理に専有されてしまうことを回避できるようになる。
まず最初にメインスレッドが専有されてしまう例を紹介し、それをワーカースレッドで解決する。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Web Worker</title> </head> <body> <p> <span id="msg">ボタンを押すと計算を開始します</span><br> <button id="cal-button" type="button">計算開始</button> </p> <p> <span id="counter">0</span><br> <button id="count-up-button" type="button">count up</button> </p> <script src="/main.js"></script> </body> </html>
ボタンが 2 つ用意されており、count up
を押下すると数字が増えていく。
そして計算開始
を押下すると何らかの演算が行われるのだが、この演算に5
秒かかるとする。
// main.js let counter = 0; const msg = document.querySelector("#msg"); const calButton = document.querySelector("#cal-button"); function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); return message; } calButton.addEventListener( "click", () => { msg.textContent = "計算中です"; msg.textContent = heavy(5000, "計算が完了しました"); }, false ); const counterText = document.querySelector("#counter"); const countUpButton = document.querySelector("#count-up-button"); countUpButton.addEventListener( "click", () => { counter += 1; counterText.textContent = counter; }, false );
この場合、計算開始
を押下すると、UI が全く更新されなくなってしまう。
計算開始
を押下してから5
秒後にようやく、更新される。
メインスレッドがheavy
関数の実行に専有されてしまい、それが終わるまで後続の処理はブロックされてしまっている。
setTimeout
を使えば計算中です
をすぐに表示させることはできるが、それでも、count up
ボタンの押下にすぐに反応させることはできない。
シングルスレッドである以上、heavy
関数の実行と UI の描画を両立させることはできない。
ワーカースレッドを使ってマルチスレッドにすれば、これを解決できる。
// main.js let counter = 0; const msg = document.querySelector("#msg"); const calButton = document.querySelector("#cal-button"); const worker = new Worker("worker.js"); calButton.addEventListener( "click", () => { msg.textContent = "計算中です"; worker.postMessage("計算が完了しました"); }, false ); worker.addEventListener( "message", (e) => { msg.textContent = e.data; }, false ); const counterText = document.querySelector("#counter"); const countUpButton = document.querySelector("#count-up-button"); countUpButton.addEventListener( "click", () => { counter += 1; counterText.textContent = counter; }, false );
// worker.js function heavy(ms, message) { const startTime = performance.now(); while (performance.now() - startTime < ms); return message; } self.addEventListener( "message", (e) => { const result = heavy(5000, e.data); self.postMessage(result); }, false );
計算開始
を押下するとワーカースレッドを呼び出し、そちらでheavy
関数を実行させている。
そしてそれと並行して、メインスレッドが UI を更新する。そしてheavy
関数の実行が終わったタイミングでメインスレッドは結果を受け取り、それを UI に反映させる。
これで、heavy
関数を実行しつつ UI を更新できるようになった。
複数のワーカースレッドを作成する
ワーカースレッドは複数作成できるし、ワーカースレッドの中でさらにワーカースレッドを作成することもできる。
以下の例ではworker.js
のなかで、ワーカースレッドとしてsub-worker.js
を呼び出している。
そしてバケツリレーのように、データを流している。
// main.js const worker = new Worker("worker.js"); worker.addEventListener( "message", (e) => { console.log(e.data); }, false ); worker.postMessage("First message."); // First message. worker sub-worker
// worker.js const subWorker = new Worker("sub-worker.js"); // メインスレッドから受け取り、サブに渡す self.addEventListener( "message", (e) => { subWorker.postMessage(e.data + " worker"); }, false ); // サブから受け取り、メインスレッドに渡す subWorker.addEventListener( "message", (e) => { self.postMessage(e.data); }, false );
// sub-worker.js self.addEventListener( "message", (e) => { self.postMessage(e.data + " sub-worker"); }, false );
HTML のなかに Worker を書く
ワーカースレッドで実行したいコードを、独立したファイルに書くのではなく HTML ファイルのなかに書くこともできる。
データブロックにコードを記述することで、ブラウザが実行してしまうことを防げる。
参考:
data block って?
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>Web Worker</title> </head> <body> <script id="worker" type="text/worker"> self.addEventListener( "message", (e) => { self.postMessage(e.data); }, false ); </script> <script src="/main.js"></script> </body> </html>
そしてメインスレッドのなかで、Blob
オブジェクトを経由して URL を生成する。
そうすれば、Worker
クラスに渡せるようになる。
// main.js const code = document.querySelector("#worker").textContent; const blob = new Blob([code], { type: "text/javascript" }); const url = URL.createObjectURL(blob); const worker = new Worker(url); URL.revokeObjectURL(url); worker.addEventListener( "message", (e) => { console.log(e.data); }, false ); worker.postMessage("foo");
Blob
オブジェクトやcreateObjectURL
については、以前書いた。
ワーカースレッドでのスクリプトのインポートについて
最後に、ワーカースレッドで他のスクリプトをインポートする方法について見ていく。
この観点から見た時、Worker は 2 種類に分類できる。
Classic Worker
とModule Worker
である。このどちらであるかによって、できることが異なる。
どちらの Worker になるかは、Worker
インスタンスの作成時に決まる。
const worker = new Worker("worker.js"); // Classic Worker const worker = new Worker("worker.js", { type: "classic" }); // Classic Worker const worker = new Worker("worker.js", { type: "module" }); // Module Worker
import
文を使えるのはModule Worker
のみで、Classic Worker
で使うとエラーになる。
Dynamic Import については、どちらの Worker でも使うことができる。
importScripts
は、Classic Worker
でのみ使える。
importScripts
にファイルを渡すと、その内容を読み込み、そこに定義されているグローバル変数が使えるようになる。
// main.js const worker = new Worker("worker.js", { type: "classic" });
// worker.js importScripts("./a.js"); // foo を使える console.log(foo);
// a.js const foo = "This is foo."; console.log("Loaded");
なお、読み込むファイル(上記の例だとa.js
)にexport
文があると、エラーになる。