自作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の「タブ税」はほぼ消えた。
デューティ比のイメージ:

一般化 — 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。


コメント