Web Workersを使用してCPUに負荷のかかるタスクを処理する方法
著者は、Girls Who Code を「Write for Donations(寄付のための執筆活動)」プログラムの一環として寄付の受取先に選びました。
イントロダクション
日本語では次のように表現できます:
JavaScriptは一貫性のあるスレッド1つだけを利用することから、通常「シングルスレッド言語」と呼ばれます。ウェブアプリケーションのコードは一連の順番で1つのスレッドで実行されます。複数のコアを持つデバイスでウェブアプリにアクセスしている場合、JavaScriptは1つのコアしか使用しません。メインスレッドでタスクが実行されている場合、次にくるタスクはタスクが完了するまで待たなければなりません。タスクが時間がかかると、メインスレッドをブロックし、残りのタスクの実行ができなくなります。一部のブロッキングタスクは、CPUを多く使用するタスクであり、CPUの処理負荷が高いタスクです。例えば、グラフィック処理、数値計算、ビデオや画像の圧縮などがあります。
CPUに依存したタスクに加えて、ブロッキングしないI/Oに依存したタスクもあります。これらのI/Oに依存したタスクは、主にオペレーティングシステム(OS)へのリクエストの発行と応答の待機に時間を費やします。例えば、Fetch APIがサーバーに行うネットワークリクエストです。Fetch APIを使用してサーバーからリソースを取得する場合、オペレーティングシステムがタスクを引き継ぎ、Fetch APIはOSの応答を待ちます。この間、Fetch APIのコールバックはキューにオフロードされ、OSの応答を待機しています。これにより、メインスレッドが解放され、他の後続のタスクを実行することができます。応答が受信されると、Fetch APIの呼び出しに関連するコールバックがメインスレッド上で実行されます。I/Oに依存したタスクのパフォーマンスは、オペレーティングシステムがタスクを完了するのにかかる時間に依存するため、FetchのようなほとんどのI/Oに依存したタスクは、プロミスを実装しています。プロミスは、タスクが完了し応答を返すときに実行されるべき関数を定義します。
その一方で、I/O バウンドのタスクは OS の応答を待つためにアイドル状態になりますが、CPU バウンドのタスクはタスクの完了まで CPU を占有し、メインスレッドをブロックします。これらをプロミスでラップしても、メインスレッドは依然としてブロックされます。さらに、ウェブアプリのユーザーインターフェイス(UI)がフリーズし、JavaScript を使用しているものが機能しなくなるため、ユーザーはメインスレッドがブロックされていることに気付くことがあります。
この問題の解決策として、ブラウザはWeb Workers APIを導入し、ブラウザ内でマルチスレッドをサポートしました。Web Workersを使用することで、CPU負荷の高いタスクを別のスレッドにオフロードすることができ、メインスレッドを解放することができます。メインスレッドはJavaScriptコードを1つのデバイスコア上で実行し、バックグラウンドでオフロードされたタスクは別のコア上で実行されます。両方のスレッドはメッセージパッシングを通じて通信し、データを共有することができます。
このチュートリアルでは、ブラウザのメインスレッドをブロックするCPUバウンドタスクを作成し、それがウェブアプリにどのように影響を与えるかを観察します。そして、プロミスを使用してCPUバウンドタスクを非ブロッキングにする試みは失敗します。最後に、メインスレッドをブロックせずにCPUバウンドタスクを別のスレッドにオフロードするために、ウェブワーカーを作成します。
前提条件
このチュートリアルに従うためには、以下のものが必要です.
- A machine with two or more cores with a modern web browser installed.
- A local Node.js environment on your system, which you can setup with How To Install Node.js on Ubuntu 22.04. On other operating systems, follow the appropriate guide on How To Install Node.js and Create a Local Development Environment.
- Knowledge of the event loop, callbacks, and Promises, which you can learn by reading Understanding the Event Loop, Callbacks, Promises, and Async/Await in JavaScript.
- You will also need a basic knowledge of HTML, CSS, and JavaScript, which you can find in our How To Build a Website With HTML series, How To Build a Website With CSS series, and in How To Code in JavaScript.
ステップ1 – ウェブワーカーを使わずにCPU負荷のあるタスクを作成する
このステップでは、ブロッキングのCPUバウンドタスクと非ブロッキングタスクが含まれたウェブアプリを作成します。このアプリケーションには3つのボタンがあります。最初のボタンは、約50億件の繰り返しを行うforループであるブロッキングタスクを開始します。2番目のボタンは、ウェブページ上の値を増やし、3番目のボタンはウェブアプリの背景色を変更します。値を増やすボタンと背景色を変更するボタンは非ブロッキングです。
始めに、mkdirコマンドを使用してプロジェクトディレクトリを作成します。
- mkdir workers_demo
cdコマンドを使ってディレクトリに移動してください。
- cd workers_demo
nanoやお気に入りのテキストエディタを使用して、index.htmlファイルを作成してください。
- nano index.html
「index.html」ファイルには、次のコードを追加して、ボタンと出力を表示する「div」要素を作成してください。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Workers</title>
<link rel="stylesheet" href="main.css" />
</head>
<body>
<div class="wrapper">
<div class="total-count"></div>
<div class="buttons">
<button class="btn btn-blocking" id="blockbtn">Blocking Task</button>
<button class="btn btn-nonblocking" id="incrementbtn">Increment</button>
<button class="btn btn-nonblocking" id="changebtn">
Change Background
</button>
</div>
<div class="output"></div>
</div>
<script src="main.js"></script>
</body>
</html>
ヘッダーセクションでは、main.cssのスタイルシートを参照し、アプリのスタイルが含まれます。ボディタグでは、total-countというクラスを持つdiv要素が作成されます。この要素には、ボタンがクリックされるたびに増加する値が含まれます。次に、3つのボタン要素を子要素として持つ別のdiv要素が作成されます。最初のボタンは、ブロックするCPU集約型のタスクを開始します。2番目のボタンでは、total-countクラスを持つdiv要素の値が増加し、3番目のボタンではJavaScriptコードが実行されて背景色が変更されます。これら2つのタスクは、非ブロッキングです。
次のdiv要素には、CPUに負荷をかけるタスクの出力が含まれます。最後に、bodyタグの終わりの前に、JavaScriptコードが含まれているmain.jsファイルを参照します。
要素にはIDとクラスがあることに気付くかもしれません。このステップの後で、JavaScriptで要素を参照するためにそれらを使用します。
今、ファイルを保存して終了してください。
メイン.cssファイルを作成し、開きます。
- nano main.css
メインの main.css ファイルに、次のコンテンツを追加して要素をスタイル化してください。
body {
background: #fff;
font-size: 16px;
}
.wrapper {
max-width: 600px;
margin: 0 auto;
}
.total-count {
margin-bottom: 34px;
font-size: 32px;
text-align: center;
}
.buttons {
border: 1px solid green;
padding: 1rem;
margin-bottom: 16px;
}
.btn {
border: 0;
padding: 1rem;
}
.btn-blocking {
background-color: #f44336;
color: #fff;
}
#changebtn {
background-color: #4caf50;
color: #fff;
}
ボタンは、ソリッドな緑色の枠線と薄いパディングで定義されています。しかし、ブロッキングタスクは、異なる背景色を使用する .btn-blocking スタイルでさらに定義されています。
ファイルを保存して閉じてください。
CSSスタイルを定義したら、HTML要素をインタラクティブにするためにJavaScriptコードを書きます。ファイルを保存して終了してください。
エディタでmain.jsファイルを作成して開いてください。
- nano main.js
メインのjsファイルに以下のコードを追加して、DOM要素を参照する。
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
最初の3行では、documentオブジェクトのgetElementByID()メソッドを使用して、ボタンのIDを参照しています。最後の2行では、documentオブジェクトのquerySelector()メソッドを使用して、div要素のクラス名を参照しています。
次に、incrementBtnボタンがクリックされた時にdiv要素の値を増やすイベントリスナーを定義してください。
...
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
まず、totalCountEl要素のテキストコンテンツを0に設定します。そして、DOMのaddEventListener()メソッドを使用してincrementBtnボタンにイベントリスナーを付けます。このメソッドは2つの引数を取ります:リスンするイベントとコールバックです。ここでは、イベントリスナーはクリックイベントをリスンし、クリックイベントが発生した時にincrementValue()コールバックを呼び出します。
incrementValue()のコールバック内で、DOMからtotalCountElのテキストコンテンツの値を取得し、それをカウンター変数に設定します。その後、値を1増やして、totalCountEl要素のテキストコンテンツを増やした値に設定します。
次に、以下のコードをchangeColorBtnボタンにクリックイベントを追加し、ボタンがクリックされたときに背景色がランダムに変更されるようにします。
...
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length)
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
前のコードでは、ユーザーがchangeColorBtnボタンをクリックしたときに、changeBackgroundColorコールバックを実行するクリックイベントリスナーを追加します。コールバックでは、colors変数を3つのHEXカラー値の配列に設定します。そして、Math.random()メソッドを呼び出し、その結果を配列の長さの値と乗算して、0から配列の要素数3までのランダムな数値を生成します。そのランダムな値は、Math.Floor()メソッドを使用して最も近い整数に丸められ、randomIndex変数に格納されます。
その後、ランダムなインデックスを使用して配列から値を選択し、それをドキュメントオブジェクトのbody.style.backgroundプロパティに設定します。
あなたが非ブロッキングタスクを実行する2つのボタンを実装したので、残りのボタンにイベントリスナーを追加して、CPUを使用するタスクを開始します。ループは50億回繰り返され、その結果はDOMに保存されます。
まだmain.jsファイルにいる場合、次のコードを追加して、ブロッキングタスクを開始するボタンにクリックイベントリスナーを付けます。
...
blockingBtn.addEventListener("click", function blockMainThread() {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
output.textContent = `Result: ${counter}`;
});
前のコードでは、blockMainThread()コールバックを実行するクリックイベントリスナーをアタッチします。関数内部で、カウンターの値を0に設定し、その後、50億回繰り返すループを作成します。各繰り返しで、カウンターの値は1ずつ増加します。ループが終了した後、計算結果は出力要素に設定されます。
以下の完全なファイルが一致するようになりました。
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length)
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
blockingBtn.addEventListener("click", function blockMainThread() {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
output.textContent = `Result: ${counter}`;
});
コード入力が終了したら、ファイルを保存して終了してください。
ステップ3でWeb Workersを使用する際にCORSエラーを回避するために、アプリケーション用のウェブサーバーを作成する必要があります。以下のコマンドを実行してサーバーを作成してください。
- npx serve .
「y」を入力して確認し、コンソールにはサーバーが実行されていることを示す「Serving!」というメッセージが表示されます。
┌─────────────────────────────────────────────────────┐ │ │ │ Serving! │ │ │ │ – Local: http://localhost:3000 │ │ – On Your Network: http://your_ip_address:3000 │ │ │ │ Copied local address to clipboard! │ │ │ └─────────────────────────────────────────────────────┘
お好みのウェブブラウザを開いて、http://localhost:3000/index.htmlにアクセスしてください。
Note
現在のターミナルで、次のコマンドを使用してウェブサーバーを起動してください:
npx serve .
続行するにはyを入力してください。
コンソールには以下のエラーメッセージが表示されるかもしれませんが、ウェブサーバーへのアクセスには影響しません:
OutputERROR: Cannot copy server address to clipboard: Couldn’t find the `xsel` binary and fallback didn’t work. On Debian/Ubuntu you can install xsel with : sudo apt install xsel.
┌─────────────────────────────────────────────────────┐
│ │
│ サービング中! │
│ │
│ – ローカル: http://localhost:3000 │
│ – ネットワーク内のアドレス: http://your_ip_address:3000 │
│ │
│ ローカルアドレスをクリップボードにコピーしました! │
│ │
└─────────────────────────────────────────────────────┘
ローカルマシンで別のターミナルを開き、次のコマンドを入力してください:
ssh -L 3000:localhost:3000 your_non_root_user@your_server_ip
ブラウザに戻り、http://localhost:3000/index.htmlに移動してアプリのホームページにアクセスしてください。
ページがロードされると、ブロッキングタスク、インクリメント、背景変更のボタンを備えたホームページが表示されます。 カウンターの増加は、まだボタンを押していないため、0から始まります。
最初に、数回「増加」ボタンをクリックして、各クリックごとにページ上の数字を更新してください。
次に、ページの背景色を変更するために「背景を変更」ボタンを数回クリックしてください。
最後に、ブロックタスクボタンを押し、無作為に増加ボタンと背景変更ボタンをクリックします。するとページが反応しなくなり、ボタンは機能しなくなります。この凍結は、ブロックタスクボタンがCPU負荷の高いタスクを開始し、メインスレッドが解放されるまで他のコードが実行されないためです。しばらく時間が経過し、CPU負荷の高いタスクが終了すると、ページには「結果:5000000000」と表示されます。この時点で他のボタンをクリックすると、再び動作を開始します。
ご経験の通り、ブロッキングタスクはユーザーにすぐにわかり、アプリケーションの使い勝手に悪影響を及ぼす可能性があります。
メインスレッドを介してアプリを凍結させるブロッキングタスクを持つアプリを作成した今、CPUを使用するタスクを非ブロッキングタスクに変換するためにプロミスを使用します。
ステップ2 – プロミスを使用してCPUバウンドタスクのオフロード
Fetch APIや他のプロミスベースのメソッドを使用してI/Oタスクを処理することは、CPUに負荷をかけるタスクをプロミスで包むことで非同期にできるという間違った印象を与えることがあります。先述のように、I/Oタスクは非同期であるのは、それらがOSによって処理され、タスクが完了したときにJavaScriptエンジンに通知されるからです。OSがI/Oタスクを処理する間、I/Oタスクに関連するコールバックはOSからの応答を待ちながらキューに入ります。キューで待機している間、メインスレッドは全ての後続のタスクを処理することができます。OSからの応答が来た時点で、コールバックはメインスレッドで実行され、コールバックの並行実行はありません。
このステップでは、CPUバウンドタスクを非ブロッキングにするために、約束(promise)でCPU集中的なタスクをラップします。
テキストエディターでmain.jsファイルを開いてください。
- nano main.js
main.jsファイルには、PromiseでCPUの負荷がかかるタスクを包むcalculateCount()関数を作成するために、強調されたコードを追加してください。
...
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
blockingBtn.addEventListener("click", function blockMainThread(){
....
})
calculateCount()関数はプロミスを返します。この関数では、new Promise構文を使用してプロミスを初期化し、コールバックを受け入れるresolveとrejectパラメータを引数に取ります。パラメータはコールバック内の操作の成功または失敗を処理します。コールバックにはCPUの負荷がかかるループが含まれており、50億回繰り返します。ループが終了した後、結果をresolveメソッドで呼び出します。
「calculateCount()」関数でCPUバウンドなタスクを持っているので、ハイライトされたコードを削除してください。
...
blockingBtn.addEventListener("click", function blockMainThread() {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
output.textContent = `Result: ${counter}`;
});
コードを削除した後、blockMainThread()関数内でcalculateCount()関数を呼び出します。この関数はPromiseを返すため、Promiseを消費するためにasync/awaitの構文を使用する必要があります。
calculateCount()関数を非同期にするために、mainスレッドをブロックするためのコードを追加して、calculateCount()関数を呼び出します。
...
blockingBtn.addEventListener("click", async function blockMainThread() {
const counter = await calculateCount();
output.textContent = `Result: ${counter}`;
});
前のコードでは、asyncキーワードを使用してblockMainThread()関数を非同期にするために、関数のプレフィックスに付けます。関数内部で、awaitキーワードを使用してcalculateCount()関数をプレフィックスし、関数を呼び出します。await演算子は、Promiseが解決するまで待機します。解決したら、カウンター変数が返された値に設定され、出力のdiv要素がCPUに負荷のかかるタスクの結果に設定されます。
今、あなたの完全なファイルは以下と一致するようになります。
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length)
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
blockingBtn.addEventListener("click", async function blockMainThread() {
const counter = await calculateCount();
output.textContent = `Result: ${counter}`;
});
変更を完了したら、ファイルを保存して終了してください。
サーバーがまだ稼働している状態で、ブラウザでhttp://localhost:3000/index.htmlをリフレッシュしてください。IncrementボタンとChange Backgroundボタンをクリックしてください。その後、Blocking Taskボタンをクリックし、他のボタンをクリックしてください。CPUバウンドタスクが実行中でも、他のボタンは反応しません。これにより、CPUバウンドタスクをPromiseでラップしてもタスクが非ブロッキングにならないことが証明されます。
CPUが負荷をかけられることを確認し、非同期処理を使って解決しようと試みたが失敗したので、Web Workersを使用してCPU集中タスクを非ブロッキングにします。
ステップ3 – Web Workersを使用してCPU負荷の高いタスクをオフロードする
このステップでは、CPUに負荷がかかるタスクをoffloadするために、CPUに負荷がかかるタスクをworker.jsファイルに移動して、専用のワーカーを作成します。main.jsファイルでは、worker.jsファイルのパスを指定して専用のWebワーカーをインスタンス化します。Webワーカーが初期化されると、CPUに負荷がかかるタスクは別のスレッドにoffloadされ、メインスレッドは残りのタスクを処理するために使用できるようになります。
最初に、worker.jsというファイルを作成してください。
- nano worker.js
あなたのworker.jsファイルに、次のコードを追加してCPUに負荷のかかるタスクを追加します。
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
これまで使ってきたCPUバウンドタスクは、前述のコードブロックに含まれています。このコードは今から別のスレッドで実行されます。
計算結果にメインスレッドがアクセスできるようにするために、WorkerインタフェースのpostMessage()メソッドを使用してデータを含むメッセージを送信する必要があります。
「worker.js」ファイルに、ハイライトされた行を追加してデータをメインスレッドに送信してください。
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
postMessage(counter);
この行では、CPUに負荷をかけるタスクの計算結果を含むカウンター変数を使用して、postMessage() メソッドを呼び出しています。
ファイルを保存して閉じてください。
CPUに負荷をかけるタスクをworker.jsに移動したので、main.jsファイルを開いてください。
- nano main.js
main.jsファイルから、CPU負荷の高いタスクがハイライトされた行を削除してください。
...
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 5_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
blockingBtn.addEventListener("click", async function blockMainThread() {
const counter = await calculateCount();
output.textContent = `Result: ${counter}`;
});
blockMainThreadコールバック内に、以下のコードを追加して、ワーカーを初期化し、ワーカースレッドからのメッセージを受信するようにしてください。
blockingBtn.addEventListener("click", function blockMainThread() {
const worker = new Worker("worker.js");
worker.onmessage = (msg) => {
output.textContent = `Result: ${msg.data}`;
};
});
最初に、以前に作成したworker.jsファイルへのパスを指定して、Workerのインスタンスを作成します。次に、Workerインターフェースのonmessageプロパティをworkerスレッドに関連付けます。これにより、workerスレッドから送られてくるメッセージを受信することができます。もしメッセージが届くと、messageイベントが発火し、コールバック関数がメッセージデータmsgを引数として呼び出されます。このコールバック関数では、Web Workerから受け取ったメッセージを使って出力テキストの内容を変更します。
完全なファイルは、次のコードブロックと一致するようになります。
const blockingBtn = document.getElementById("blockbtn");
const incrementBtn = document.getElementById("incrementbtn");
const changeColorBtn = document.getElementById("changebtn");
const output = document.querySelector(".output");
const totalCountEl = document.querySelector(".total-count");
totalCountEl.textContent = 0;
incrementBtn.addEventListener("click", function incrementValue() {
let counter = totalCountEl.textContent;
counter++;
totalCountEl.textContent = counter;
});
changeColorBtn.addEventListener("click", function changeBackgroundColor() {
colors = ["#009688", "#ffc107", "#dadada"];
const randomIndex = Math.floor(Math.random() * colors.length)
const randomColor = colors[randomIndex];
document.body.style.background = randomColor;
});
blockingBtn.addEventListener("click", function blockMainThread() {
const worker = new Worker("worker.js");
worker.onmessage = (msg) => {
output.textContent = `Result: ${msg.data}`;
};
});
ファイルを保存して終了してください。
サーバーが稼働している状態で、ウェブブラウザに戻って http://localhost:3000/index.html を訪れてください。ページはサーバーから正常に読み込まれます。
まず、インクリメントボタンと背景変更ボタンを数回クリックしてください。次に、CPUを使用するタスクを開始するためにブロッキングタスクボタンをクリックし、他のボタンを続けてクリックしてください。CPUを多く使用するタスクが実行中でも、これらのボタンは問題なく動作します。
専用のWeb Workerを使用して、CPUの負荷が高いタスクを非ブロッキングにすることができます。
結論
このチュートリアルでは、メインスレッドをブロックするCPUバウンドタスクを開始するアプリを作成しました。その後、非ブロッキングなCPUバウンドタスクを作るためにプロミスを使用しようとしましたが、うまくいきませんでした。最終的には、専用のWeb Workerを使用して、CPUバウンドタスクを別のスレッドにオフロードして非ブロッキングにしました。
次のステップとして、専用のWebワーカーに詳細な情報を提供しているWeb Workers APIを訪れることができます。専用のWebワーカーに加えて、Web Workers APIにはShared WorkersやService Workersも含まれており、オフラインアクセスの提供やパフォーマンスの向上に利用することができます。
Node.jsを使用すれば、『Node.jsでマルチスレッドを使用する方法』でワーカースレッドの使い方を学ぶことができます。