Skip to content
Published on

ブラウザでシステムをシミュレートする — WASMが可能にしたことと、いまだ阻むこと

シェア
Authors

はじめに — ひとつのタブの中にTCPスタック2本、x86 CPU1個、Debian1台

2026年7月28日、Hacker NewsにSimulating TCP loss and congestion in browser using Go/WASMが投稿されました。リンクを開くと、CUBICとBBRv3の輻輳ウィンドウが並んで描かれます。ただし、そのグラフを作っているのはデータファイルではなく、ブラウザの中で実際に動いている2つのgVisor TCPスタックです。リポジトリの説明もそうなっていますし、ブラウザが実際にダウンロードする.wasmファイルを直接確認すると、8,664,912バイト、gzipで約2.35MBでした。

これは孤立した事例ではありません。v86はx86マシンコードを実行中にWebAssemblyモジュールへ翻訳し、Windows 98、ReactOS、9frontを起動させ、スター数は23,000を超えました。WebVMはCheerpXというエンジンの上で手を加えていないDebianを動かします — x86-to-WASM JIT、ブロック単位の仮想ファイルシステム、Linuxシステムコールエミュレーターを備えた代物です。2026年7月15日にはFirefox丸ごとをWebAssemblyにビルドしたデモが273ポイントを獲得しました。

しばらくの間、こうしたものは「動くことは動くが、何のためにやっているのか分からない」というデモに近い扱いでした。今は違います。この記事は、このパターンがなぜ今実用的になったのか、どんな制約がいまだに設計を支配しているのか、そして優れた教材とおもちゃを分ける境界線がどこにあるのかを整理します。

実用的になった理由 — Wasm 3.0が埋めたピース

WebAssembly 3.0は2025年9月17日にW3Cコミュニティグループでリリースされ、9つの機能が一度に正式化されました。システムシミュレーションの観点で意味のあるものだけを抜き出すとこうなります。

例外処理がネイティブになりました。以前はC++やRustのアンワインディングをJavaScriptと往復させるか、Asyncifyのような変換でごまかすしかなく、どちらもコストが高いものでした。OSをエミュレートするコードがトラップや割り込みを扱う経路は、まさにここに引っかかります。

Memory64 によって64ビットアドレッシングが可能になりました。wasm32の4GiBアドレス空間という天井が消えるという意味で、大きなゲストメモリを確保する必要のあるエミュレーターにとっては直接の解放になります。ただしタダではありません — 64ビットインデクシングは境界チェックのコストが増え、wasm32より遅くなります。4GiBに収まるワークロードなら、wasm32のほうがまだ有利です。

WasmGC は、管理された言語(Java、Kotlin、Dartなど)が独自のGCを同梱せずホストVMのGCを使えるようにします。バイナリサイズが大きく縮む経路であり、Safariを含む主要ブラウザがサポートしています。ただしGoとRustはこの経路を使いません — Goは独自のGCを線形メモリの上で動かし、Rustにはそもそも GCがありません。

ここに128ビットSIMDテールコール複数メモリ型付き関数参照分岐ヒントが加わります。テールコールはインタープリタループに、複数メモリはゲストメモリとホストのデータ構造を分離するのに使えます。

まとめると、2020年頃のWASMは「純粋な計算を高速に回す箱」であり、今のWASMは「例外とGCと大きなメモリを扱える実行環境」です。その違いが、ブラウザの中でシステムソフトウェアを動かすという発想を現実的にしました。ブラウザの外のWASMについてはブラウザの外のWebAssemblyで、実際にブラウザで動く開発ツールについてはブラウザで本物のエンジンが動く:WebAssembly開発ツール集でまとめています。

制約1 — ソケットがない

もっとも根本的な制約です。ブラウザのサンドボックスは生ソケットを与えません。任意のTCP接続もUDPも、ましてrawIPもありません。使えるのはfetch、WebSocket、WebRTCデータチャネル、WebTransportだけで、すべて上位プロトコルです。

この一点が、ブラウザのシステムシミュレーターをきっちり二つに分けます。

第一の道は、ネットワークをまるごとシミュレーションすることです。 ccsimがこちらです — 送信者と受信者、その間のリンクがすべてプロセスの中にあるので、そもそも外へ出る必要がありません。リンクモデルはトークンバケットで帯域を制限し、遅延とジッターを加え、シードされた乱数で損失を作り、taildrop・RED・CoDel・FQ-CoDelのいずれかでキューを管理し、ECN CEマーキングまで行います。実際のネットワークが不要というレベルではなく、ないほうがいいのです — 再現可能になるからです。

第二の道は、下の層をトンネリングすることです。 v86にはNE2000 PCIネットワークカードがエミュレートされていますが、ゲストが送り出したイーサネットフレームは結局WebSocketリレーを通って実際のネットワークへ出ていかなければなりません。WebVMはもっと露骨です — ネットワーキングをTailscaleでつなぎ、公共のインターネットへ出るにはexit nodeを使えと案内しています。そしてREADMEにはこんな注記が付いています。

低レベルのネットワーキング操作の一部(特にpingが使うICMP)は、現在この環境では利用できません。接続確認にはcurlwgetを使ってください。

ブラウザの中に完全なLinuxがあるのに、pingが使えません。この一行が制約の正確な形を示しています — ICMPはソケット層より下にあり、トンネルの反対側がそれを代わりに作ってくれない限り存在しません。

設計上の教訓はこうです。ネットワークの挙動を教えたいシミュレーターなら、第一の道を選ぶべきです。 トンネリングはリレーサーバーという依存を生み、そのリレーがシミュレーションしようとしていたまさにその特性(遅延、損失、キュー)を汚染します。逆に、実際のソフトウェアが動く様子を見せることが目的なら、トンネリング以外に道はありません。

制約2 — スレッドはHTTPヘッダー2行にぶら下がっている

WebAssemblyで本当の並列実行をするには、複数のワーカーが同じ線形メモリを共有する必要があり、そのためにはSharedArrayBufferが要ります。ところがSpectre系の脆弱性以降、SharedArrayBufferクロスオリジン分離されたドキュメントでのみ使えます。条件は応答ヘッダー2行です。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

credentiallessで緩和できますが、本質は同じです。そしてこの2行が及ぼす影響は思ったより大きいです。

  • ページが読み込むあらゆるクロスオリジンのサブリソースが、CORPまたはCORSヘッダーを持っていなければなりません。画像CDN、フォント、アナリティクススクリプト、埋め込みYouTube — どれか一つでもヘッダーがなければ読み込みがブロックされます。
  • COOPはwindow.opener関係を切断します。OAuthのポップアップフローや親ウィンドウとの通信に依存するコードは壊れます。
  • 何より、サーバー設定を自分で制御できなければなりません。 GitHub Pagesのような静的ホスティングに応答ヘッダーを追加できないなら、この経路自体が閉じます。サービスワーカーで回避するハックは知られていますが、初回アクセスでは動作せず、デバッグも厄介です。

そしてGoを使うなら、この議論自体が無意味になります。GOOS=js GOARCH=wasmターゲットはシングルスレッドです。 ゴルーチンは一つのJavaScriptイベントループの上で多重化されるので、ゴルーチンをいくつ立ち上げてもコアは一つしか使いません。ccsimがワーカー一つで動くのもそのためです。ただしccsimの場合、これは損ではなくむしろ設計要件でした。決定性のために、すべてのnetstack TCP処理をイベントループのゴルーチンでインラインに強制しているからです。

ヘッダーが実際に付いているか確認するのは一行で済みます。そしてローカルの開発サーバーはデフォルトでこのヘッダーを送らないので、自分で付ける必要があります。

# デプロイ済みページに分離ヘッダーが付いているか
curl -sI https://example.com/app/ | grep -i 'cross-origin-'

# ローカルで付けてみる(Python標準ライブラリだけで)
python3 - <<'PY'
import http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
        self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
        super().end_headers()
http.server.ThreadingHTTPServer(('', 8000), H).serve_forever()
PY

ブラウザのコンソールでcrossOriginIsolatedがtrueかどうかを見れば最終確認です。falseならSharedArrayBufferコンストラクタ自体が存在しません。

シングルスレッドでUIを固まらせない実務パターンは決まっています。シミュレーションはワーカーで動かし、メインスレッドとはメッセージだけをやり取りし、ワーカーの中でも一定単位ごとに処理を譲ります。ccsimのワーカーグルーは、シミュレーション時間250ms単位でバッチを区切り、MessageChannelで処理を譲ってから次のバッチへ進みます。そのため実行が終わるまで待つ必要がなく、チャートが少しずつ埋まっていきます。

制約3 — バイナリサイズと起動時間

ここが言語選択で実際に差が出るポイントです。直接確認した値と広く報告されている値を分けて整理するとこうなります。

ツールチェーン代表的な成果物サイズスレッド特徴
Go(GOOS=js GOARCH=wasm)ccsim実測8.66MB、gzip 2.35MB。最小限のサンプルでも通常MB単位シングルスレッド固定ランタイムとGCを丸ごとリンク。標準ライブラリ全体がそのまま使われる
TinyGoコミュニティ報告によれば標準Go比10〜20倍小さい制限あり使う分だけリンク。リフレクションと一部の標準ライブラリに制約
Rust(wasm-bindgen)小さいモジュールは数十KB水準SABがあれば可能ランタイムが事実上ない。wasm-optでさらに10〜30%削減
C/C++(Emscripten)コード量に比例SABがあれば可能既存のネイティブコードベースを移植する経路。v86のかなりの部分がこの系統

直接確認する方法も簡単です。自分のプロジェクトをWASMでビルドして二つの値を比較すれば、どちらのコストが問題なのかすぐに分かります。

# Go: 標準ツールチェーン
GOOS=js GOARCH=wasm go build -o main.wasm ./cmd/sim
ls -l main.wasm
gzip -9 -c main.wasm | wc -c        # 実際の転送量はこちら
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .

# どのシンボルがサイズを食っているか
go tool nm -size -sort size main.wasm | head -30

# TinyGoで同じコードをビルドして比較(制約があるか先に確認すること)
tinygo build -o tiny.wasm -target wasm -opt=z ./cmd/sim

# Binaryenがあればもう一段削る
wasm-opt -Oz main.wasm -o main.opt.wasm && ls -l main.opt.wasm

Goの8.66MBを見て「GoはWASMに向かない」と結論づける前に、その8.66MBに何が入っているかを見る必要があります。ccsimの場合、gVisorの完全なTCP/IPスタック2本、RACK/TLPを含む損失検知、SACKスコアボード、BBRv3ステートマシン、リンクモデルと4種類のキュー規律が入っています。これをRustで書き直すコストと8.66MB(転送は2.35MB)のどちらが大きな問題かは状況次第です。gVisorがGoで書かれているという事実が、言語選択をすでに決めていたのであり、それがおおむね正しい判断基準です。

Goの本当のコストはサイズより起動時間であることが多いです。wasm_exec.jsグルー(約17KB)を読み込み、8MBを超えるモジュールをコンパイル・インスタンス化し、Goランタイムが初期化され、GCが準備できて初めて最初の行が実行されます。Rustはこのうち後半二つの段階が事実上ありません。

実行速度も無視できません。ccsimリポジトリの受け入れ基準表は正直です — 同じ30秒シミュレーションが、ネイティブarm64で1.4秒、Node v26のWASMで7.6秒です。約5.4倍です。この程度の差がUIにどう現れるかもコードに痕跡が残っていて、画面下部にこんな警告が付いています。

デフォルト値は事前に計算されたものです — スライダーを動かすと、この端末上でシミュレーターが実際に動き、時間がかかることがあります。

作った側のブログにも「古い端末では1分を超えることに気づき、デフォルトシナリオを事前に計算しておいた」と書かれています。ブラウザシミュレーターを作るときほぼ必ず出会う決断です — 最初の画面は事前計算した結果を即座に描き、インタラクションが始まったときだけ実際に動かす。

メモリの面も触れておく価値があります。wasm32のアドレス空間は4GiBですが、実際に1つのタブで安定して確保できる線形メモリはそれよりずっと小さく、ブラウザやプラットフォームによって異なります。モバイルSafariは特にケチです。ゲストRAMを大きく取るエミュレーターはここで先に詰まるので、v86のデモの大半が128MB前後の古いシステムであるのも偶然ではありません。

思わぬ収穫 — 決定性

ブラウザシミュレーターを作りながら得られる副次効果のうち、もっとも価値があるのがこれです。WASMターゲットにはスレッドがなく、時間はホストが与え、乱数もホストが与えます。だから決定性を得やすい環境なのです。そして決定的なシミュレーターは、それ自体が回帰テストになります。

ccsimはこれを最後まで押し通した事例なので、素材がそのまま参考になります。リポジトリが明かす決定性の構成要素は4つです。

  1. 仮想クロックひとつ。 netstackタイマー、リンクイベント、アプリケーションの書き込み、サンプリングティック、シナリオ注入がすべて一つの最小ヒープに乗り、同着ならFIFOで決着します。時間の出所は正確に一つです。
  2. インラインディスパッチ。 すべてのnetstack TCP処理をイベントループのゴルーチンで同期的に処理するようgVisorをパッチしました。どのゴルーチンもクロックと競合しません。
  3. 名前付きPCGサブストリーム。 リンク損失(順方向/逆方向)、RED判定、到着時刻、各スタックの乱数源、フローごとのBBRプローブジッターが、それぞれ別々のサブストリームを持ちます。ある箇所の呼び出し回数が変わっても、他の箇所の乱数列は揺らぎません。
  4. FMA融合の遮断。 シミュレーション経路のすべての浮動小数点乗算加算に明示的なfloat64変換を入れ、コンパイラがFMAへ融合できないようにしました。arm64とamd64とwasmが同じビットを出すためです。

この4番目が特に示唆的です。クロスプラットフォームの決定性を望むなら、浮動小数点演算の融合の有無まで制御する必要があるということで、ほとんどのプロジェクトはここまでやりません。その代償として得たものは明確です — ネイティブビルドとWASMビルドのサンプルストリームがバイト単位で同一であり、それがテストで強制されます。ブラウザで見たグラフとCIで動かした結果が同じものだという保証です。

教材としてこれがなぜ重要かというと、「自分のブラウザでは違う結果になるのですが」という疑問がそもそも成立しないからです。

優れた教材とおもちゃを分ける境界線

ブラウザシミュレーターが本当に何かを教えるものになるには、いくつかの条件が揃わなければなりません。失敗する側と並べてみると、その境界線が見えてきます。

本物の実装を動かしているか、それともアニメーションか。 ccsimはgVisorの実際のTCPコードを動かします。だからSACKスコアボードが100レンジで溢れたときの挙動、RACK並び替え判定、ECNエコーといったものが、近似ではなく実物です。逆に、スライダーを動かすとあらかじめ描いておいた曲線を補間するだけのものは、「輻輳制御はこんな感じだ」という印象を与えるだけで、想定外の組み合わせでは真実を語りません。想定外の組み合わせで真実を語ることこそが、シミュレーターの唯一の存在理由です。

検証されているか。 ccsimはCUBICの成長曲線をRFC 9438の三次関数に当てはめて決定係数を記録し、REDマーキング曲線をカイ二乗検定にかけ、ゴールデンストリーム回帰テストを実行します。検証されていないシミュレーターは、誤った直感を自信満々に植え付けます。何も学ばないより悪いことです。

境界を明かしているか。 フローが一つだけで、ボトルネックが一つで、ミドルボックスがなく、CPUと割り込み処理がないという事実を伝えるシミュレーターと、伝えないシミュレーターは別物です。

起動が速いか。 8MBをダウンロードして30秒待たないと最初の絵が出てこない教材は、誰も見てくれません。事前計算したデフォルト値で即座に描く選択が、教育的価値の半分近くを左右します。

逆に、この方式がおもちゃで終わる典型的な条件も明確です。実際のハードウェアタイミングが本質であるテーマ(キャッシュ階層、NUMA、割り込み遅延)をブラウザでシミュレーションすると、学べることより誤解することのほうが多くなります。規模が本質であるテーマ(数千のフローの相互作用、大規模クラスタのテール遅延)も同様です。そして実際のネットワークと相互作用してこそ意味があるテーマなら、先に見た通りリレーがシミュレーション対象を汚染します。

一文にまとめるとこうなります — ブラウザシミュレーターは「閉じた系の中でルールがどう相互作用するか」を教えるのに強く、「現実世界がどれほど乱雑か」を教えるのには弱いのです。

おわりに — リンク一本で配布される実行環境

まとめるとこうです。

  • Wasm 3.0の例外処理、Memory64、WasmGCが、「計算の箱」だったWASMをシステムソフトウェアが動く実行環境に変えました。
  • 生ソケットがないという制約が、設計を二つに分けます。ネットワークをまるごとシミュレーションするか(ccsim)、リレーでトンネリングするか(v86、WebVM)。教えることが目的なら前者です。
  • 本当の並列実行はSharedArrayBufferに、それはCOOP・COEPヘッダーに、それはサーバーの制御権にかかっています。Goターゲットなら、この議論自体がありません — シングルスレッドです。
  • Goは大きく(実測8.66MB、gzip 2.35MB)起動が遅いですが、移植したいコードがすでにGoなら、それが決定的な要因です。実行速度はネイティブ比5倍前後を覚悟する必要があります。
  • WASMターゲットの制約(スレッドなし、ホストが与える時間と乱数)が決定性を得やすくします。決定的なシミュレーターは、それ自体が回帰テストです。

そしてこのすべての制約を受け入れるだけの理由が一つあります。インストールもアカウントもクラスターも要らず、リンク一本で実行可能なシステムを配布できるということです。教材として、その性質に勝るものはあまりありません。

参考資料