Web 工程

现代 Web 应用中的 WebAssembly(WASM)指南

从 C/C++ 和 Rust 编译出的二进制模块,如何在浏览器里达到接近原生的性能。

WebAssembly 到底是什么

WebAssembly 是一种二进制指令格式,也是一个编译目标。很少有人手写它。编译器接收 C、C++、Rust、Zig 或 Go 代码,输出一个模块;浏览器验证这个模块,再把它编译成机器码。这个格式刻意做得很小,也刻意做得很无趣:一台栈式虚拟机,用结构化控制流取代任意跳转,类型系统严格到验证只需对字节做一次线性扫描。正是这些限制,让浏览器可以在模块还在下载的时候就开始编译。

它不是 JavaScript 的替代品。模块访问不了 DOM,访问不了网络,也没有系统调用。它能做算术,能读写自己的内存,能调用宿主明确导入给它的函数。其余一切仍然是 JavaScript 的事。实际项目里两者都要交付:一个负责计算的模块,加上一层很薄的 JavaScript,负责把字节送进去、把结果取出来。

为什么编解码工作应该交给 WebAssembly

常见的说法是快,而这种说法反倒掩盖了更好的理由。现代 JavaScript 引擎很快,有时快得惊人,但它们靠的是推测。JIT 观察流经某个函数的类型,编译出一个特化版本,再加上守卫。一旦传入它没料到的值,守卫失效,代码被去优化,执行退回到慢速路径,直到重新恢复。在应用代码里这毫无感觉;在解码器的内层循环里,这就是一帧画到一半时的卡顿。

WebAssembly 模块把类型直接带在二进制里。在任何代码运行之前,编译器就知道每个值的形状,所以没什么要猜,之后也没什么要作废。线性内存也不受垃圾回收管理,热路径上不用为回收停顿留余地。两者的峰值吞吐量往往比人们以为的更接近,差距是在稳定性上拉开的,而稳定性才是用户真正能感觉到的东西:进度条匀速前进,而不是一顿一顿地往前蹿。定宽 128 位的 SIMD 指令在这里也帮得上忙,因为像素循环和音频采样循环正好是最适合向量化的形状。

还有一个与性能无关的理由。图像和视频编解码器、PDF 渲染器、OCR 引擎和语音模型,早已以成熟的 C 和 C++ 库的形式存在,多年的错误报告和畸形输入的边界情况都已被它们消化。用 JavaScript 重写 libjpeg 或 Tesseract 不是一个周末就能完成的项目,而且在很长一段时间里,结果都会比原版差。WebAssembly 让您把现成的东西编译一下,直接交付。生产环境里的大多数 WebAssembly 就是为此而存在的,这个理由比任何基准测试都站得住脚。

线性内存,以及标签页为什么有上限

模块的内存是一块连续的缓冲区。模块内部的指针就是这块缓冲区里的偏移量,它以 64 KiB 为一页增长;在 wasm32 下,指针宽 32 位,可寻址内存因此封顶在 4 GiB。浏览器远在到达这个数字之前就会停下,手机停得还要早得多。由此产生三个后果,生产环境里出问题的正是它们。

增长实际上是单向的。memory.grow 会扩大缓冲区,但当模块的分配器释放一块内存时,这块内存回到的是模块自己的堆,而不是浏览器。所以只要这个实例还活着,一次大任务就会抬高整个标签页的内存峰值。如果您要连续处理大文件,在两次任务之间销毁实例、重新创建一个,比指望 free 归还任何东西都更可靠。

增长还需要一段连续的地址空间。机器明明还有大量空闲内存,grow 却可能失败,原因只是地址空间碎片化了。这在 32 位构建和内存紧张的手机上最常见,而且它是以操作进行到一半时抛出异常的形式出现的,不会在开头礼貌地拒绝您。

最后,数据必须跨边界复制。JavaScript 无法把 File 对象直接交给模块。您要先把文件读进 ArrayBuffer,把这些字节复制到线性内存,执行操作,再把结果复制出来构造 Blob。有那么一刻,输入在内存里存了两份,外加编解码器自己需要的工作集。预算要按文件大小的好几倍算,而不是一倍;在界面上设一个明确的大小限制;格式允许的话就用流式处理。您自己写的一句拒绝,胜过您没写的一次崩溃。

您必须交付的那个二进制文件

一个正经编解码器的 WebAssembly 构建产物以 MB 计,而且它不像 JavaScript 那样好压缩。压缩过的 JavaScript 是重复性很强的文本,gzip 最喜欢这种东西。编译出的模块本身已经是紧凑的二进制,所以压缩比更差,Brotli 能帮一点忙。机器学习模型更麻烦:跟权重比起来,运行时的体积只是个舍入误差。由此得出的规则很简单,但仍然有人违反。不要在页面加载时加载模块。等用户真正决定执行操作时再加载。

有几个细节能让这种延迟加载没那么难受。把文件以 application/wasm 类型提供,这样 WebAssembly.instantiateStreaming 才能一边接收字节一边编译,而不是先缓冲完整个下载;MIME 类型错了,这个好处就悄无声息地没了。给文件一个带内容哈希的 URL 和一个 immutable 缓存头,让回访不花任何代价。对于较大的模块,浏览器还会缓存编译好的代码,回访者连编译都能省掉,不只是下载。

本站就是按这个前提搭建的。首屏加载里没有任何 WebAssembly 相关的东西。OCR 工具只在您真正运行 OCR 时才注入它的引擎;转写工作线程在您第一次转写时才去获取语音模型,之后靠浏览器缓存。把 JPG 转成 PNG 然后离开的人,这些一个都不会下载,因为那条路径根本不经过 WebAssembly:只是一次 canvas 绘制加一次编码调用,两者都由浏览器自己的原生代码完成。

哪些场合 WebAssembly 是错误答案

它访问不了 DOM,所以每一次 DOM 操作都要通过胶水代码回到 JavaScript。编译成 WebAssembly 的界面框架,每次交互都要交这笔过路费。这次跨越很便宜,但不是免费的,而且一直在发生。

字符串因为同样的原因而别扭。没有共享的字符串类型,文本要在边界上编码、复制、再解码。以字符串处理为主的工作负载,花在搬运数据上的时间可能比计算还多。

短任务很少能收回投入。下载、编译、实例化,每一步都要花实实在在的时间。如果计算本身只需几毫秒,模块永远赚不回这些时间。

而当平台已经用原生代码做了这件事,再自带一份就是倒退。WebCodecs 负责视频的编码和解码。Web Audio 负责音频图。Canvas 负责图片的解码、缩放和重新编码。把一个重采样器包进 1 MB 的 WebAssembly 里,去做 drawImage 本来就会做的事,是这个错误最典型的版本。

还有一点,与其说是限制,不如说是陷阱。WebAssembly 调用是同步的。一次长时间运行的调用会阻塞它所在的线程;在主线程上,这意味着页面冻结,跟一个长 JavaScript 循环的效果完全一样。重型模块应该放进 Worker。这不是可选的建议。

值得先回答的几个问题

  • 是否已经有一个成熟的原生库能正确解决这个问题?这是编译它的最强理由,也是用 JavaScript 重新实现它的最弱理由。
  • 这项工作是否是数值型的、自成一体的,而且时间长到足以摊平下载和编译的成本?对类型化缓冲区做的紧凑循环,就是能赢的形状。
  • 是否已有浏览器 API 覆盖了它?如果 WebCodecs、Web Audio 或 canvas 能处理这种情况,就用它们,什么都不用交付。
  • 这个二进制文件能不能等到用户真正请求操作时再加载?实例在标签页余下的生命周期里一直占着的那部分内存,您能否接受?
  • 它能在 Worker 里运行吗?如果它必须在主线程上运行,而且耗时超过一帧,解决办法是把它挪走,而不是优化它。

一旦您不再指望它取代 JavaScript,这项技术就变得平淡无奇。它是一种把现有原生代码放进一个从未为它设计的沙箱里运行的办法,带着一个您必须尊重的内存模型和一笔您必须支付的下载开销。从第一天起就把这两项成本当作设计约束,剩下的就是普通的工程工作。