- 引言 — 一个标签页里的两套 TCP 协议栈、一颗 x86 CPU、一台 Debian
- 现在为什么可行 — Wasm 3.0 补上的拼图
- 限制一 — 没有套接字
- 限制二 — 线程系于两行 HTTP 头
- 限制三 — 二进制体积与启动时间
- 意外的收获 — 确定性
- 优秀教具与玩具之间的界线
- 结语 — 一个链接就能分发的可运行环境
- 参考资料
引言 — 一个标签页里的两套 TCP 协议栈、一颗 x86 CPU、一台 Debian
2026 年 7 月 28 日,Hacker News 上出现了 Simulating TCP loss and congestion in browser using Go/WASM 这篇帖子。打开链接,你会看到 CUBIC 和 BBRv3 的拥塞窗口并排绘制出来,但画出这张图的不是什么数据文件,而是两套在浏览器里真实运行的 gVisor TCP 协议栈。仓库里的说明就是这么写的,而直接查看浏览器实际下载的.wasm文件也证实了这一点:8,664,912 字节,gzip 压缩后约 2.35MB。
这并非孤例。v86 在运行时把 x86 机器码翻译成 WebAssembly 模块,借此启动 Windows 98、ReactOS 和 9front,star 数已经突破 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 社区组正式发布,一次性把九项特性定稿。只挑系统模拟视角下有意义的部分来看,是这样的。
异常处理变成了原生特性。在此之前,要么把 C++ 或 Rust 的栈展开在 JavaScript 里来回折腾,要么用 Asyncify 之类的转换手法硬凑,两种做法都不便宜。模拟 OS 的代码在处理陷阱和中断时,走的正是这条路径。
Memory64 让 64 位寻址成为可能。这意味着 wasm32 那道 4GiB 地址空间的天花板消失了,对需要分配大块 guest 内存的模拟器来说是直接的解放。但这不是免费的——64 位索引的边界检查开销更大,比 wasm32 慢。如果工作负载能塞进 4GiB,wasm32 依然更划算。
WasmGC 让托管语言(Java、Kotlin、Dart 等)直接使用宿主 VM 的 GC,而不必自带一套。这是让二进制体积大幅缩小的一条路径,包括 Safari 在内的主流浏览器都已支持。不过 Go 和 Rust 都不走这条路——Go 在线性内存之上跑自己的 GC,Rust 则根本没有 GC。
再加上128 位 SIMD、尾调用、多重内存、带类型的函数引用、分支提示。尾调用对解释器循环有用,多重内存可以用来把 guest 内存和宿主的数据结构分开。
归纳一下,2020 年前后的 WASM 是「把纯计算跑快的一个盒子」,而今天的 WASM 是「能处理异常、GC 和大内存的运行环境」。这个差距,才是把「在浏览器里跑系统软件」这个想法变得现实的原因。浏览器之外的 WASM 故事,写在浏览器之外的 WebAssembly这篇里;真正在浏览器里运行的开发工具,写在浏览器里跑着真引擎:WebAssembly 开发工具集这篇里。
限制一 — 没有套接字
这是最根本的限制。浏览器沙箱不提供原始套接字。任意的 TCP 连接不行,UDP 不行,遑论 raw IP。能用的只有fetch、WebSocket、WebRTC 数据通道、WebTransport,而且全都是高层协议。
仅这一点,就把浏览器系统模拟器精确地劈成了两条路线。
第一条路线是把整个网络都模拟出来。 ccsim 走的是这条路——发送方、接收方,以及它们之间的链路,全都活在同一个进程里,压根不需要往外走。链路模型用令牌桶限制带宽,注入延迟和抖动,用带种子的随机数制造丢包,用 taildrop、RED、CoDel、FQ-CoDel 之一管理队列,甚至做 ECN CE 标记。这不只是不需要真实网络这么简单,而是没有反而更好——因为这样才可复现。
第二条路线是把下层隧道化。 v86 模拟了一块 NE2000 PCI 网卡,但 guest 发出的以太网帧最终还是得穿过一个 WebSocket 中继才能抵达真实网络。WebVM 做得更直白——它把网络接到 Tailscale 上,并且告诉你要访问公共互联网就用 exit node。它的 README 里还留着这样一句注记。
部分底层网络操作(尤其是
ping所用的 ICMP)目前在这个环境里不可用。要确认连通性,请使用curl或wget。
浏览器里跑着一套完整的 Linux,ping却用不了。这一行精确地画出了这个限制的形状——ICMP 位于套接字层之下,除非隧道另一端替它造出来,否则它根本不存在。
从设计的角度看,教训是这样的。如果一个模拟器的目的是教网络行为,就该选第一条路线。 隧道化会引入对中继服务器的依赖,而那个中继恰恰会污染你原本想模拟的那些特性(延迟、丢包、排队)。反过来,如果目的是展示真实软件真的能跑起来,除了隧道化就没有别的路。
限制二 — 线程系于两行 HTTP 头
要在 WebAssembly 里做到真正的并行执行,多个 worker 必须共享同一块线性内存,而这需要SharedArrayBuffer。但自从 Spectre 系列漏洞之后,SharedArrayBuffer只能在跨源隔离的文档里使用。条件就是两行响应头。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
可以用credentialless放宽一些,但本质不变。而这两行头的影响范围比想象中大得多。
- 页面加载的每一个跨源子资源都必须带有 CORP 或 CORS 头。图片 CDN、字体、埋点脚本、内嵌的 YouTube——只要有一个缺了头,加载就会被挡下来。
- COOP 会切断
window.opener关系。依赖 OAuth 弹窗流程或者与父窗口通信的代码会因此坏掉。 - 最重要的是,你必须能控制服务器配置。如果在 GitHub Pages 这类静态托管上没法添加响应头,这条路直接就断了。用 service worker 绕过去的手法是有人知道,但首次访问不生效,调试起来也很讨厌。
而如果用 Go,这整套讨论直接就没有意义了。GOOS=js GOARCH=wasm这个目标是单线程的。 goroutine 全部复用在同一个 JavaScript 事件循环上,不管你起多少个 goroutine,用到的始终只有一个核。ccsim 只在一个 worker 里跑,原因就在这里。不过对 ccsim 来说,这不是损失,反而是设计要求——为了确定性,它把所有 netstack TCP 处理都强制内联在事件循环的 goroutine 里执行。
确认头是否真的带上了,一行命令就够。而本地开发服务器默认不会发送这些头,得自己加上。
# 已部署页面是否带着隔离头
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 卡死的实务套路是固定的。模拟放到worker里跑,和主线程只用消息往来,worker 内部也按一定单位让出控制权。ccsim 的 worker 胶水代码按 250ms 的模拟时间切一批,用MessageChannel让出去,再接着跑下一批。所以不用等到整个运行结束,图表会逐步被填满。
限制三 — 二进制体积与启动时间
这是语言选择真正拉开差距的地方。把亲自验证过的数字和广泛流传的数字分开整理,是这样的。
| 工具链 | 典型产物体积 | 线程 | 特点 |
|---|---|---|---|
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 协议栈、包含 RACK/TLP 的丢包检测、SACK 记分板、BBRv3 状态机、链路模型,外加四种排队规则。把这些用 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 上如何体现,代码里也留了痕迹,屏幕底部挂着这样一条提示。
默认展示的是预先算好的结果——拖动滑块,模拟器就会在这台设备上真正跑起来,可能需要一点时间。
做这个项目的人在自己的博客里也写道,「发现在旧设备上要跑一分钟以上,于是把默认场景预先算好了」。做浏览器模拟器时几乎必然会撞上一个决定——首屏用预先算好的结果立即画出来,只有交互真正开始时才去跑真正的计算。
内存这一块也值得说说。wasm32 的地址空间是 4GiB,但实际在一个标签页里能稳定拿到的线性内存,比这个小得多,而且因浏览器和平台而异。移动版 Safari 尤其吝啬。要占用大块 guest RAM 的模拟器会在这里先撞墙,所以 v86 的演示大多是 128MB 上下的旧系统,也不是巧合。
意外的收获 — 确定性
做浏览器模拟器过程中得到的附带效果里,最值钱的就是这个。WASM 目标没有线程,时间由宿主给,随机数也由宿主给。所以这是一个容易获得确定性的环境。而一个确定性的模拟器,本身就是一份回归测试。
ccsim 把这一点做到了极致,所以它的做法直接就能当参考。仓库里列出的确定性构成要素有四个。
- 唯一的一个虚拟时钟。 netstack 定时器、链路事件、应用层写入、采样节拍、场景注入,全部挂在同一个最小堆上,同时发生就按 FIFO 决出先后。时间只有一个来源。
- 内联派发。 给 gVisor 打了补丁,让所有 netstack TCP 处理都在事件循环的 goroutine 里同步执行。没有任何 goroutine 会和时钟竞争。
- 具名的 PCG 子流。 链路丢包(正向/反向)、RED 判定、到达时刻、各协议栈自己的随机数源、每条流各自的 BBR 探测抖动,各自拥有独立的子流。某处调用次数变了,不会扰动别处的随机数序列。
- 阻断 FMA 融合。 在模拟路径上的每一次浮点乘加运算里都插入显式的
float64转换,不让编译器把它融合成 FMA。目的是让 arm64、amd64 和 wasm 都吐出完全相同的比特。
第四点尤其能说明问题。想要跨平台的确定性,得连浮点运算融不融合都控制住,大多数项目根本不会做到这一步。换来的东西很明确——原生构建和 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,SharedArrayBuffer系于 COOP、COEP 头,而这两行头系于你对服务器的控制权。如果目标是 Go,这整套讨论根本不存在——它是单线程的。 - Go 体积大(实测 8.66MB,gzip 2.35MB)、启动慢,但如果要移植的代码本来就是 Go,这就是决定性因素。执行速度得做好比原生慢 5 倍上下的心理准备。
- WASM 目标的限制(没有线程、时间和随机数都由宿主提供)让确定性变得容易获得。一个确定性的模拟器,本身就是一份回归测试。
而愿意承受这一切限制,还有一个理由。不用安装,不用账号,也不用集群,一个链接就能分发一个可运行的系统。作为教具,能压过这个特性的东西不多。
参考资料
- Simulating TCP loss and congestion in browser using Go/WASM — Hacker News (item 49088098)
- apoxy-dev/ccsim — 确定性设计、WASM 一致性、性能验收标准
- BBRv3 for gVisor's netstack — 开发方技术博客
- copy/v86 — x86 PC 模拟器与 x86-to-wasm JIT
- leaningtech/webvm — 基于 CheerpX 的浏览器 Linux 虚拟机
- Mini.WebVM — 从 Dockerfile 构建浏览器 Linux 主机
- WebAssembly 3.0 发布公告(2025-09-17)
- MDN — SharedArrayBuffer 与跨源隔离要求
- Go Wiki — WebAssembly 目标指南
- TinyGo — 二进制体积优化指南
- 浏览器之外的 WebAssembly(相关文章)
- 浏览器里跑着真引擎:WebAssembly 开发工具集(相关文章)
현재 단락 (1/89)
2026 年 7 月 28 日,Hacker News 上出现了 [Simulating TCP loss and congestion in browser using Go/WASM](https...