RSS Twitter Facebook

2022/12/30 (2022年12月 のアーカイブ)

WebWorkerでゴリゴリの重い処理をさせて横から制御したい時の手段


これは Javascript のかなり細かい部分の話なので似たようなケースで困った事がある人以外にとってはどうでも良い話だと思いますが......。

JavaScript は元々シングルスレッド構造なのであまり重い処理をさせるには向いていないのだ、という事は昔から言われていました。 重い処理をしようとした時にまず影響を受けやすいのは UI 周りの動作です。

UI をちゃんと動かしつつ重い処理をさせるためには、処理を小分けにしてタイマーから駆動する等の手法がとられます。 またこの時、コールバックで小分けにした処理を繋げるとソースが見づらくなるので Promise や async/await を使う、という手段が定石となっていったのですが、そもそも、ひとまとまりの重い処理をやらせたいならやっぱり別スレッドで走らせたい、という事で WebWorker というものが作られました。

これによって Javascript は OS レベルで保証されたマルチスレッドな環境を手に入れた訳です。ただし、この時のメインスレッドとワーカースレッドは空間が分離されており、スレッド間の通信は postMessage() によるメッセージのやり取りで行う必要があります。

まあこれで大体のやりたい事はできるようになったのですが、次のコードを見てください。メインスレッドからの指示で WebWorkerで 10 秒かかる処理を開始/中止するつもりのコードです。

"START" ボタンを押すとワーカー側で 10 秒数える無限ループ的な処理を開始し、"ABORT" ボタンで中止フラグを立てているつもりですが、残念ながらこれはうまく動きません。

main.js

let worker = new Worker('worker-1.js');

document.getElementById('start').addEventListener('click', ()=>{
    worker.postMessage('start');
    console.log('post "start"');
});

document.getElementById('abort').addEventListener('click', ()=>{
    worker.postMessage('abort');
    console.log('post "abort"');
});

worker-1.js


let abort = 0;
let current = 0;

function start() {
    abort = current = 0;
    console.log('Heavy task start');
    const startTime = new Date();
    while(current < 10) {
        const elapsed = Math.floor((new Date() - startTime)/1000);
        if(elapsed != current) {
            console.log(current = elapsed);
        }
        if(abort) {
            console.log('Heavy task abort');
            break;
        }
    }
    console.log('Heavy task end');
}

onmessage = (ev)=>{
    switch(ev.data) {
    case 'start':
        console.log('recv "start"')
        start();
        break;
    case 'abort':
        console.log('recv "abort"');
        abort = 1;
        break;
    }
}
これを走らせると次のようになります。

"START" を押すと数を数え始めるのは良いのですがカウントが 5 になった時に "ABORT" を押しても止まらず、10 まで数え終わってから "abort" を受け取っています。メイン側は "abort" メッセージを送信していますが、ワーカー側に届いていません。ワーカーはメッセージを受け取るための onmessage の処理をマイクロタスクとしてキューに入れますがこのマイクロタスクは現在実行中のタスクが終わらないと走り始めないためです。結局ワーカー側は重い処理の途中で送られたメッセージを受け取る暇もなく動き続けるという事になります。

単にワーカーを止めたいだけならメイン側から直接 worker.terminate() を呼び出す事もできますが、一旦停止して後で続きを再開させたい、とか、重い処理の途中で追加の情報を送りたい、とかいう場合には対応できません。

元々シングルスレッドしか想定していなかった所にマルチスレッドを持ち込んだからこうなっちゃったのかなとは思いますが、こういう場合どうするか、と言うと、こういう場合に使える手段はちゃんとあります。それが SharedArrayBuffer、略して SAB 等とも呼ばれるもので、これはスレッド間の共有メモリとなります。ワーカー側でメッセージを受け取る暇がなくても処理を中断するためのフラグを立てるだけならメイン側の処理で行う事が可能です。

これを使うと下のコードのようになります。ここでは共有メモリとして 1 バイトの中止フラグだけを作っています。なお、もっと大きなサイズで複雑なデータをやり取りする事も可能ですが、高度な操作をするのならスレッド間の競合も気にする必要がでてきますので、Atomic API で競合を避ける必要があります。

main.js

let worker = new Worker('worker-2.js');
let sab = new SharedArrayBuffer(1);
let abort = new Uint8Array(sab);

document.getElementById('start').addEventListener('click', ()=>{
    worker.postMessage(['start', sab]);
    console.log('post "start"');
});

document.getElementById('abort').addEventListener('click', ()=>{
    abort[0] = 1;
    console.log('set "abort" flag');
});

worker-2.js

let abort, current;

function start() {
    current = 0;
    abort[0] = 0;
    console.log('Heavy task start');
    const startTime = new Date();
    while(current < 10) {
        const elapsed = Math.floor((new Date() - startTime)/1000);
        if(elapsed != current) {
            console.log(current = elapsed);
        }
        if(abort[0]) {
            console.log('Heavy task abort');
            break;
        }
    }
    console.log('Heavy task end');
}

onmessage = (ev)=>{
    switch(ev.data[0]) {
    case 'start':
        const sab = ev.data[1];
        abort = new Uint8Array(sab);
        console.log('recv "start"')
        start();
        break;
    }
}
これを走らせたのが下の図で、重い処理の途中でも "ABORT" ボタンを押したタイミングで止まってますね。



めでたしめでたし、ではあるのですが、この SAB にまつわる問題はこれまでに結構な紆余曲折があり、CPU レベルでのセキュリティ上の懸念があるという指摘により対応するブラウザのリリースが延期されたりしていたのです。ちなみに WebWorker がサポートされ始めたのが 2010 年頃、その後 SAB が一度提案されたものの問題の指摘により一旦無効化され、最終的に対応方法が決まったのが 2021 年頃なので結構長い間この、マルチスレッドではあるけどちょっと使いづらい問題は引きずっていた気がします。

SAB を有効化するために必要な対応が「クロスオリジン分離 (cross-origin-isolation) 」で、ブラウザでこの機能を使うにはサーバ側で COOP および COEP と呼ばれる特殊なヘッダーを設定する必要があります。このヘッダーがないとブラウザはこの SAB の API 自体をサポートしていないものとして扱います。

Chrome では 2021 年の Chrome 91 でクロスオリジン分離を必須とする対応になる時、使っているライブラリ内で知らず知らずの内に暫定の対応経由で SAB を使っていたサイトに対して google からもうすぐ動かなくなるよと警告が送られてちょっとした騒ぎになったりしていました。

まあ自分が管理しているサーバならば必要なヘッダーを追加してやれば良いのですが、ヘッダーを勝手にいじれない例えば GitHub Pages で使いたい場合はどうすれば?? とここで詰んだかと思ったのですが、やる人はいるもので、WebWorker の親戚のサービスワーカー(ServiceWorker) という機能を使って SAB に必要なヘッダーを補完するライブラリ (coi-serviceworker) が作られています。

https://github.com/gzuidhof/coi-serviceworker

npm が使えるなら npm i --save coi-serviceworker でインストールできます。これを使って

<script src="node_modules/coi-serviceworker/coi-serviceworker.js"></script>

という風に読み込んでやればサーバ側のヘッダー設定をいじれなくても SAB を使う事が可能になります。

なお更に追記ですが、このサービスワーカーはプッシュ通知等で使われる事が多いのですが、ユーザーが知らない所でバックグラウンドで動作するとして嫌う人もいます。ブラウザの設定でサービスワーカーを拒否するような措置が取られている場合には coi-serviceworker を使う事はできません。できれば素直に自分でヘッダー設定が可能なサーバを使いましょう。



Posted by g200kg : 2022/12/30 11:24:45