ブラウザだけ1.5倍遅い — WebWorkerの setTimeout(0) が4msずつ止まっていた (JavaScript/WebAssembly)

Rust

自作x86エミュレータ rustx86 には、ずっと説明のつかない数字があった。同じwasmバイナリなのに、Node.js (headless) では14秒で終わるLinuxブートが、ブラウザだと21秒かかる。約×1.5。タブの描画やpostMessageの往復を「タブ税」と呼んで許容していたが、×1.5は多すぎる。

犯人は、教科書どおりに書いた2文字だった。

症状: 無音の×1.5

エミュレータ本体はWorkerの中で回っている。設計はよくある形だ:

function loop() {
  emu.run_slice(sliceSize);   // ~8ms ぶん命令を実行 (wasm内でブロック)
  // …シリアル出力の転送、状態報告…
  setTimeout(loop, 0);        // 「0ms後」に次のスライス。キー入力を捌く隙を作る
}

run_sliceはwasmの中でブロックするので、走りっぱなしにするとWorker宛のキー入力メッセージが永遠に届かない。だから毎スライス、一度イベントループに戻る。そのためのsetTimeout(loop, 0)である。どのチュートリアルにも載っている形だ。

この税の質が悪いのは、完全に無音なことだ。エラーも警告も出ない。CPUメーターは忙しそうに見える。プロファイラには「待機」としか写らない。headlessとの比率だけが、何かがおかしいと言い続けていた。

犯人: HTML仕様のタイマクランプ

HTML仕様にはこう書いてある — タイマのネストが5段を超えたら、遅延は最低4msに切り上げる。

2000年代、setTimeout(fn, 0)で無限ループする行儀の悪いページからCPUを守るために入った、歴史あるスロットリングである。そして「自分を再予約するループ」は、まさにこの定義に当たる。5スライス目からは、setTimeout(loop, 0)は実質setTimeout(loop, 4)になる。

うちのスライス目標は8msだった。つまり:

8ms働く → 4ms強制休憩 → デューティ比 8/12 ≒ 67% = ×1.5

headlessとの差、小数点まで説明がついた。Node.jsが無傷だったのは、headlessハーネスがタイマを使わず同期whileで回していたからである。

修正: MessageChannel (実質1行)

イベントループに戻りたいだけなら、タイマである必要はない。MessageChannelの自分宛メッセージはクランプの対象外のマクロタスクだ:

// OK: クランプ無しで「次のマクロタスク」に予約する
const wake = new MessageChannel();
wake.port1.onmessage = () => loop();

function loop() {
  emu.run_slice(sliceSize);
  // …
  wake.port2.postMessage(0);  // ← setTimeout(loop, 0) をこれに置き換えるだけ
}

大事なのは、応答性が犠牲にならないこと。port.postMessageもマクロタスク境界を挟むので、Worker宛のキー入力メッセージは同じFIFOに並んで今までどおり割り込める。違いは「4ms待たされるかどうか」だけ。diffにすると、機能変更は5行(チャネル作成4行+呼び替え1行)だった。

なお、アイドル時(ゲストがHLTで寝ている間)のsetTimeout(loop, 50)わざと実時間を待つ箇所なので、そのまま残している。クランプが問題なのは「待ちたくないのに待たされる」場合だけである。

実測

Linuxブート (同一カーネル) 修正前 修正後
ブラウザ vmlinux直接 (6億命令) 21s 15s
ブラウザ bzImageフル (10億命令) 43s 23s
(基準) Node.js headless 14.0〜14.5s 変わらず

残る差は約7% — これがUI描画とメッセージングの正味のコストで、×1.5の「タブ税」はほぼ消えた。

デューティ比のイメージ:

setTimeoutクランプのタイムライン

一般化 — Workerで計算ループを回す全アプリの話

これはエミュレータ固有の話ではない。Workerで重い計算を刻みながら回すアプリ全部(物理シミュレーション、動画処理、wasm移植したゲーム、機械学習の推論ループ…)が同じ形を書く。そしてsetTimeout(fn, 0)で刻んでいるなら、同じ税を払っている可能性が高い。

チェックリスト:

  • 計算ループの自己再予約にsetTimeout(fn, 0)を使っていないか → MessageChannelへ
  • 新しめのChromeならscheduler.postTask()(優先度つき・クランプ無し)も選べる。ただしSafari/Firefoxの対応状況を確認 — MessageChannelは全ブラウザで動く枯れた手
  • デューティ比を測る: performance.now()でスライス実行時間と周回時間を別々に取り、比を出す。67%のような「きれいな比率」が出たら、それはスケジューラの税である
  • 環境比較(Node vs ブラウザ)の比率が熱やJITで説明できないときは、実行エンジンではなくループの回し方を疑う

教訓

「教科書どおりに書いたのに罠」というのが今回の型である。setTimeout(fn, 0)は間違いではない — ただ、25年前のWebを守るための仕様が、2026年のwasm計算ループにも等しく適用されるだけだ。この型はリポジトリの「踏んだ罠の型」集の筆頭に、NG/OKのサンプルコードつきで収めた。

リポジトリ

github.com/yoshiharu-ishii/rustx86 — 修正の実PRは #68

コメント

タイトルとURLをコピーしました