RSS Twitter Facebook


« 2022年09月 | 2022年12月のアーカイブ | 2023年05月 »

2022/12/30

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 : 11:24 AM : PermaLink

2022/12/25

GAME言語インタプリタを作ってみた


1970 年代の終わり頃、8bit CPU を使ったパーソナルコンピューターが各社から出そろって何とか趣味でコンピューターが使えるようになった当時、使用可能な言語処理系と言えば BASIC かマシン語をハンドアセンブルするしかなかった時代にそれに飽き足らなくなったマニア達が独自の言語処理系を作るというのが流行った事がありました。

中でも私が気に入っていたのは GAME 言語という奴。その源流は Altair の VTL (Very Tiny Language) と呼ばれるものらしいです。マイクロ BASIC 風ですが予約語は全部記号の組み合わせで表すという見るからに怪しいコードがとても良い雰囲気でした。しかもインタプリタだけでなくコンパイラも存在し、さらにそのコンパイラが GAME 言語自体で記述されているという構造がとてもそそります。詳細な言語仕様もかなりおぼろげだったんですが、最近 GitHub に当時の GAME コンパイラのコードが公開されているのを知りました。
https://github.com/snakajima/game-compiler

GAME 言語の文法については (http://www.mztn.org/game86/あたりを参照してください。

このコンパイラは80系CPUが前提のようですぐには試せないので、とりあえずオンラインでテストできるインタプリタを Javascript で書いてみました。ただし、速度がとんでもなく遅いです。これは値の評価途中でキー入力を待つケースがあり、値の評価自体をあまり考えずに async/await だらけで書いたためで、もうちょっと構造を考え直す必要はありそうです。

簡単なサンプルを幾つか付けてありますので選択して "Load" ボタンを押すとロードされます。

GitHub : https://github.com/g200kg/game-interpreter
デモ : https://g200kg.github.io/game-interpreter/gameinterpreter.html

posted by g200kg : 8:53 PM : PermaLink

« 2022年09月 | 2022年12月のアーカイブ | 2023年05月 »


-->

g200kg