- ブログ
javascriptでマルチスレッド処理
2023.09.22 Fri

エンジニアのikkaです。
ikkaの前回の記事は、データ暗号化と復号についての記事でした。今回は、そこで発生した問題と解決までのお話です。
前回同様に開発環境は、Mac(Apple M2チップ) + Laravel 10 + Vue 3 を使用しています。
問題と原因
復号処理は対象文字列の長さにもよりますが、時間のかかる処理です。
処理の間、ブラウザ画面上で動きがないと固まっているように見えてしまうため、Bootstrap5のかっこいいプログレスバーを使って進捗を表現することにしました。
ところが、いざ試してみると進捗0%で固まってしまい、すべての復号が終わった瞬間100%表示に切り替わる見え方になってしまいました。
調べてみると、復号処理がスレッドを占有してしまい、その間は描画処理を行えなかったことが原因でした。
ちなみにこの時はボタン操作なども受け付けなくなるので本当に固まったような状態となり、使う側からすると非常に不安に感じるため何としても解消する必要があります。
ブラウザは原則シングルスレッド
Webブラウザは基本的に1タブあたり1スレッド(このスレッドのことを以後、便宜的にメインスレッドと呼称します。)で動作しています。
メインスレッドはスクリプトの処理や画面描画を含む各種処理を行なっているため、復号処理にリソースを占有されてしまうとプログレスバーの更新といった他処理が後回しにされてしまいます。
これを解決するためWeb Worker APIを使用し、メインスレッドとは別のスレッド(以後、ワーカースレッドと呼称します。)を作成します。
復号処理のようなリソースを要する処理をワーカースレッドで実行することで、プログレスバーの更新などの処理を遮らないようにします。
ワーカースレッドの処理を作る
ワーカースレッドの処理をファイル読み込みで行えるよう、別ファイルに書き出します。
import JSEncrypt from "jsencrypt";
interface MessageEvent {
data: {
dataList: string[];
privateKey: string;
};
}
const decrypt = (enc: string, privateKey: string): string | false => {
const jsEncrypt = new JSEncrypt();
jsEncrypt.setPrivateKey(privateKey);
let ret = "";
// base64エンコード文字列の末尾にある==を目印に分割
const encArray = enc.split("==");
let errorFlag = false;
encArray.forEach((element: string) => {
if (!errorFlag && element != "") {
const decrypted = jsEncrypt.decrypt(element + "==");
if (decrypted === false) {
errorFlag = true;
return; // foreachを抜けるreturn
}
ret += decrypted;
}
});
return errorFlag ? false : ret;
};
// メインスレッドからのpostMessage()を受信して復号処理を行うためのイベントリスナーを設定
self.addEventListener("message", (event: MessageEvent) => {
const dataList = event.data.dataList;
const resultList: string[] = [];
for (let i = 0; i < dataList.length; i++) {
// 進捗をメインスレッドに送信
self.postMessage({ event: "progress", progress: i });
const data = dataList[i];
if (data == "") {
resultList.push("");
continue;
}
const result = decrypt(data, event.data.privateKey);
if (result === false) {
// 復号に失敗した場合はメインスレッドにエラーを送信して終了
self.postMessage({
event: "error",
progress: i,
message: "復号に失敗しました。暗号キーファイルが正しいことを確認してください。",
});
return;
}
resultList.push(result);
}
// 最後に完了と復号した文字列レコードの配列をメインスレッドに送信
self.postMessage({ event: "complete", progress: dataList.length, resultList: resultList });
});
メインスレッドからワーカースレッド、もしくはその逆方向へのデータ受け渡しは、メッセージを送受信することで行います。
今回はメインスレッドからのメッセージで暗号化文字列のレコードが入った配列と、復号に使用するための秘密鍵を受け取るようにしました。
また、ワーカースレッドからは進捗、完了、エラーの各タイミングでメインスレッドにメッセージを送信するようにしました。
メインスレッドの処理を作る
メインスレッドはワーカーオブジェクトの作成と処理の開始、ワーカーからのメッセージ受信時処理を書いていきます。
以下、抜粋です。
import { saveAs } from "file-saver";
import DecryptWorker from './decrypt.worker?worker';
interface WorkerMessage {
data: {
event: 'progress' | 'complete' | 'error';
progress: number;
resultList?: string[]; // eventがcomplete時のみ
message?: string; // eventがerror時のみ
}
}
// ワーカーオブジェクトを生成
const worker = new DecryptWorker();
// ワーカーからのメッセージを受信した際の処理を登録
worker.onmessage = (message: WorkerMessage) => {
switch (message.data.event) {
case 'progress':
// 進捗
updateProgress(message.data.progress);
break;
case 'complete':
// 完了
updateProgress(message.data.progress);
const resultList = message.data.resultList!;
// 復号データをファイルダウンロードする
const fileName = 'decrypt.txt';
saveAs(new Blob([resultList.join("\n")], { type: "text/plain;charset=utf-8" }), fileName);
break;
case 'error':
console.error('worker error : ' + message.data.message!);
break;
}
};
// ワーカーで例外が発生した際の処理を登録
worker.onerror = (err: any) => {
console.log('worker exception!');
};
// ワーカーにメッセージを送信し、処理を開始する
worker.postMessage({ dataList: encryptDataList, privateKey: privateKey });
この例ではワーカースレッドは1つのみですが、少々手を加えることで複数のワーカースレッドを作成することも可能です。
複数スレッドに分散させる分、処理時間を短縮させることができます。
ただPCのコア数より多くのワーカースレッドを作成しても効果はないようですので、window.navigator.hardwareConcurrencyの値以下にしておくのが良いでしょう。
| ワーカースレッド数 | 10000件のレコード復号にかかった時間 |
| 1 | 127秒 |
| 2 | 67秒 |
| 3 | 45秒 |
| 4 | 35秒 |
| 5 | 32秒 |
| 6 | 30秒 |
| 7 | 28秒 |
| 8 | 27秒 |
| 9 | 28秒 |
※本サンプルは、Vite開発サーバーでは、ワーカースレッドを動作させることができないため、ビルドを行う必要があります。