2017年3月,WebAssembly在所有主流浏览器中落地。一时间,“JavaScript已死"的论调甚嚣尘上。毕竟,一个能让C++代码在浏览器中以接近原生速度运行的二进制格式,听起来像是动态语言的终结者。

八年过去了。JavaScript依然统治着Web开发,而WebAssembly的使用场景被牢牢限制在图像处理、视频编解码、游戏引擎等计算密集型任务中。为什么曾经被寄予厚望的"JavaScript杀手"没能兑现承诺?

答案藏在浏览器架构的深层约束中。这不是营销失败,而是技术权衡的必然结果。

45%的性能缺口从何而来

2019年,马萨诸塞大学阿默斯特分校的研究团队在USENIX ATC会议上发表了一篇论文,题为《Not So Fast: Analyzing the Performance of WebAssembly vs. Native Code》。这是学术界首次对WebAssembly进行大规模性能评估,使用的是业界标准的SPEC CPU基准测试套件。

结果令人意外:编译为WebAssembly的应用程序,在Chrome中平均比原生代码慢55%,在Firefox中慢45%。峰值情况下,性能差距达到2.5倍。

这个数字与早期的乐观预期形成鲜明对比。WebAssembly官方论文曾宣称,在PolyBenchC科学计算基准测试中,大部分测试项的性能与原生代码差距在10%以内。但PolyBenchC的测试程序平均只有100行代码,与真实应用相去甚远。

研究团队开发了Browsix-WASM框架,使未经修改的Unix应用程序能够在浏览器中运行。这让他们能够测试完整的SPEC CPU程序,而不仅仅是玩具级的科学内核。真正的性能差距由此浮出水面。

寄存器压力:被蚕食的计算资源

WebAssembly代码在浏览器中执行时,面临的第一个问题是寄存器短缺。

x86-64架构提供16个通用寄存器。但浏览器引擎需要为自身预留部分寄存器:Chrome的V8引擎保留r13指向垃圾回收根数组,r10和xmm13作为专用暂存寄存器;Firefox的SpiderMonkey保留r15指向堆起始位置,r11和xmm15作为JavaScript暂存寄存器。

这意味着WebAssembly代码实际可用的寄存器比原生代码少2-3个。对于寄存器密集型的工作负载,影响可能不大。但当寄存器需求接近上限时,缺少这几个寄存器就会导致频繁的栈溢出(spilling)——将寄存器内容存储到内存,稍后再加载回来。

论文数据显示,Chrome生成的WebAssembly代码执行了2.02倍的加载指令和2.30倍的存储指令,Firefox则为1.92倍和2.16倍。每一次额外的内存访问,都在消耗宝贵的CPU周期。

寄存器分配器的差距

更深层的问题在于寄存器分配算法。Chrome和Firefox的WebAssembly JIT编译器都使用线性扫描(Linear Scan)寄存器分配器,这是一种快速的贪心算法,能够在编译时保持较低延迟。作为对比,Clang等原生编译器使用基于图着色的贪心分配器,能够产生更优的寄存器分配方案。

快速与优化之间的权衡,在JIT编译器中是常见的主题。但对于WebAssembly而言,这个权衡的代价由运行时性能承担。

论文中的矩阵乘法案例展示了这一差距:Clang生成的原生代码使用28条指令,10个寄存器,无栈溢出;Chrome生成的代码使用53条指令,13个寄存器,包含3次栈溢出。代码体积接近翻倍,而额外的内存访问进一步拖慢执行。

安全检查的必然代价

WebAssembly的设计哲学是安全优先。每一条间接调用都需要运行时检查,确保目标函数存在且类型匹配。每一次函数调用都需要检查栈是否溢出。这些检查保障了WebAssembly的沙箱安全性,但也带来了额外的分支指令。

论文数据显示,WebAssembly代码执行的分支指令比原生代码多1.65倍至1.75倍。更多的分支意味着更多的指令缓存失效——WebAssembly程序的L1指令缓存失效率比原生代码高2.04倍至2.83倍。

这不是实现缺陷,而是设计权衡。WebAssembly选择了安全性,也就接受了这些检查的性能代价。

xychart-beta
    title "WebAssembly vs Native Code Performance Counters (Relative to Native = 1.0)"
    x-axis ["Loads", "Stores", "Branches", "Cond. Branches", "Instructions", "I-Cache Misses"]
    y-axis "Relative to Native" 0 --> 3.5
    bar [2.02, 2.30, 1.75, 1.65, 1.80, 2.83]
    bar [1.92, 2.16, 1.65, 1.62, 1.75, 2.04]

数据来源: USENIX ATC 2019论文《Not So Fast: Analyzing the Performance of WebAssembly vs. Native Code》,基于SPEC CPU基准测试的Chrome和Firefox平均值。

DOM边界的隐形墙

如果说与原生代码的差距是WebAssembly的"先天不足”,那么无法直接访问DOM则是它的"后天限制"。

WebAssembly的内存模型与JavaScript截然不同。WebAssembly使用线性内存(Linear Memory)——一个可以按索引访问的字节数组。JavaScript则使用垃圾回收的对象图。这两种模型之间的转换成本,构成了WebAssembly与DOM交互的主要障碍。

当WebAssembly代码需要创建一个DOM元素时,必须调用JavaScript胶水代码。传递字符串需要在WebAssembly线性内存中分配空间、复制数据、传递指针,然后JavaScript代码再读取并转换。这个过程的开销可能超过操作本身的价值。

Mozilla在2017年的技术文章中详细对比了JavaScript和WebAssembly的处理流程差异。图表清晰地展示了WebAssembly在解析、编译、重优化和垃圾回收方面具有优势,但这些优势主要针对计算密集型任务。一旦涉及DOM操作,WebAssembly就必须通过JavaScript桥梁,而这个桥梁的成本往往抵消了计算性能的提升。

一个直观的例子:假设一个WebAssembly模块需要频繁读取和修改DOM元素的样式。每次操作都需要跨越Wasm-JS边界,而边界跨越的成本可能比原生JavaScript直接操作DOM还要高。这就是为什么许多尝试用WebAssembly重写前端框架的项目最终没能成功。

线性内存的两难选择

WebAssembly的内存模型是其性能特征的核心,也是其局限性的根源。

线性内存是一个简单的字节数组,通过32位或64位地址访问。这种设计使C/C++/Rust等语言的内存模型能够直接映射到WebAssembly,避免了复杂的对象模型转换。但也带来了几个问题。

无法归还的内存

WebAssembly的线性内存可以通过memory.grow指令扩展,但没有memory.shrink指令。这意味着一旦内存被分配给WebAssembly模块,即使不再使用,也无法归还给操作系统。

对于移动设备而言,这是一个严重的问题。iOS和Android经常会终止后台运行的高内存占用进程。如果WebAssembly应用的峰值内存使用量很高,即使大部分内存已经释放,操作系统仍然会看到那个峰值占用。

2023年的WebAssembly运行时基准测试显示,当使用最快的运行时(iwasm)时,WebAssembly的执行速度仅比原生代码慢约2.32倍(中位数)。但内存管理的差距无法通过运行时优化解决——这是WebAssembly规范层面的限制。

垃圾回收语言的困境

对于C/C++/Rust这类手动管理内存的语言,线性内存不是问题——它们本就在自己的内存空间中实现分配器。但对于Java、C#、Python、Go等依赖垃圾回收的语言,线性内存带来了巨大的工程挑战。

垃圾回收器需要扫描栈上的引用(指针)。但WebAssembly的执行栈不在线性内存中,无法直接读取。解决方案是"影子栈"(Shadow Stack)——在线性内存中维护一个镜像栈,存储需要被GC扫描的引用。但这增加了二进制体积和运行时开销。

2023年11月,V8团队发布了WebAssembly GC支持,允许WebAssembly模块使用与JavaScript相同的垃圾回收器。这解决了部分问题,但新的限制随之而来:GC对象不能跨线程共享,没有弱引用,没有终结器。Go语言的垃圾回收器使用了"内部指针"(interior pointer)优化,而WebAssembly GC不支持这种模式。

多线程的浏览器困境

WebAssembly的多线程支持依赖于Web Worker和SharedArrayBuffer。这个设计在理论上可行,但在实践中遇到了安全性的障碍。

Spectre的阴影

2018年曝光的Spectre漏洞改变了一切。Spectre利用CPU的推测执行机制,通过测量内存访问延迟来读取本应隔离的内存区域。攻击的关键是精确计时,而SharedArrayBuffer恰好提供了一种精确计时的方法:一个线程持续递增共享计数器,另一个线程通过读取计数器差值来测量时间。

为了缓解这个漏洞,浏览器采取了两种措施:降低performance.now()的精度,以及限制SharedArrayBuffer的使用。后者要求网站启用跨源隔离(Cross-Origin Isolation),通过设置特定的HTTP响应头来实现。

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

这组头部配置意味着网站需要审查所有跨源资源的加载策略,增加了运维复杂度。对于许多网站而言,这个代价过高。

主线程不能阻塞

即使SharedArrayBuffer可用,WebAssembly多线程仍然面临浏览器事件循环的约束。浏览器主线程负责渲染和用户交互,不能被阻塞。WebAssembly的memory.atomic.wait32/64指令可以在工作线程中使用,但在主线程中会抛出异常。

这意味着,当主线程需要获取锁时,只能使用自旋锁(spinlock),不断轮询直到锁可用。自旋锁在等待期间持续消耗CPU资源,效率远低于操作系统的阻塞等待。

对比原生应用,Go语言的goroutine调度使用栈切换和阻塞等待,性能优异。但编译为WebAssembly后,Go必须模拟调度器,在单线程中执行所有goroutine,性能大打折扣。

冷启动的真实成本

WebAssembly的设计目标之一是快速启动。二进制格式比JavaScript源码更紧凑,解析速度更快——Mozilla的数据显示,WebAssembly的解析速度比asm.js快约20倍。但快速解析不等于快速执行。

浏览器引擎对WebAssembly采用分层编译策略。第一层使用快速编译器生成未优化代码,使程序能够立即开始执行;第二层使用优化编译器对热点代码进行优化。这意味着程序执行一段时间后,性能会突然提升——但在此之前,运行的是未优化代码。

Figma在2017年发布的案例研究显示,切换到WebAssembly后,加载时间减少了3倍以上。但这个改进主要来自asm.js到WebAssembly的格式差异,而非执行效率的提升。更重要的是,Figma是一个计算密集型的设计工具,其核心渲染引擎完全在WebAssembly中运行,与JavaScript的交互相对较少。

对于交互频繁的Web应用,冷启动的劣势更加明显。第一次函数调用时,WebAssembly模块可能还未完成优化编译,性能不如已经热身的JavaScript引擎。只有当计算量足够大、运行时间足够长时,WebAssembly的性能优势才能体现。

真正的价值场景

WebAssembly没有取代JavaScript,但它在特定领域找到了不可替代的位置。

计算密集型任务的正确归宿

图像处理、视频编解码、密码学运算、科学计算——这些任务的共同特点是计算量大、边界交互少。以FFmpeg的WebAssembly版本为例,视频转码的核心循环完全在WebAssembly中运行,只需要在开始和结束时与JavaScript交换数据。中间的数百万次迭代不涉及任何DOM操作,WebAssembly的性能优势得以充分发挥。

Google Earth的Web版本使用WebAssembly渲染3D地球。AutoCAD Web使用WebAssembly处理复杂的几何计算。Photoshop Web版本使用WebAssembly实现图像处理核心。这些应用的设计模式高度一致:WebAssembly处理计算,JavaScript负责UI和DOM。

跨语言复用的桥梁

WebAssembly提供了复用现有C/C++/Rust代码库的途径。libsodium密码学库只需一次编译,就能在浏览器中运行。SQLite数据库引擎编译为WebAssembly后,可以在前端实现本地数据存储。这些库经过了数十年的优化和测试,重写的成本远高于直接移植。

沙箱安全的额外价值

WebAssembly的安全性设计使其成为运行不可信代码的理想选择。Figma的插件系统使用WebAssembly解释器执行第三方代码,插件无法访问任意Web API。服务器端的WebAssembly运行时(如WasmEdge、Wasmtime)可以安全地执行用户提交的代码,无需担心恶意代码破坏宿主系统。

未来可能的方向

WebAssembly的规范仍在演进。几个提案可能改变当前的性能格局:

WasmGC 已经在Chrome 119+中默认启用,允许编译为WebAssembly的垃圾回收语言直接使用浏览器的垃圾回收器。这简化了编译器实现,但目前的限制(如不能跨线程共享GC对象)仍需进一步解决。

Memory64 提案支持64位地址空间,突破4GB内存限制。但64位地址的边界检查无法使用虚拟内存技巧优化,每次内存访问都需要显式的范围检查,性能代价不可忽视。

Component Model 提案试图标准化跨模块接口,允许在接口层面传递字符串、记录、列表等高级类型,而非仅限于数字。这可能简化Wasm-JS互操作,但也会引入额外的数据复制。

Stack Switching 提案将允许WebAssembly实现轻量级线程(如goroutine),无需修改代码即可实现协程调度。这可能改变Go等语言在WebAssembly中的性能困境。

但无论规范如何演进,WebAssembly与DOM之间的鸿沟不太可能消失。浏览器架构决定了DOM操作必须通过事件循环进行,而事件循环的协调需要JavaScript参与。这不是技术债,而是浏览器安全模型的核心约束。

尾声

WebAssembly没能取代JavaScript,不是因为它不够快,而是因为它选择了不同的设计目标。

JavaScript的设计目标是灵活性和易用性。动态类型、垃圾回收、原型继承——这些特性让快速开发成为可能,也让编译器优化变得困难。WebAssembly的设计目标是可预测性和安全性。静态类型、线性内存、结构化控制流——这些特性让编译器能够生成高效代码,也让运行时能够提供强隔离保证。

两种设计各有优劣,互为补充。用WebAssembly写DOM操作是错配,用JavaScript写视频编解码也是错配。正确的工具选择,建立在对工具局限性的清晰认知之上。

2025年的Web开发实践已经给出了答案:JavaScript负责应用逻辑和UI层,WebAssembly负责计算密集型核心。这不是妥协,而是分工。浏览器在成为通用计算平台的过程中,需要不同层次的抽象。WebAssembly和JavaScript,正是这个分层架构中的两块基石。


参考资料

  1. Jangda A, Powers B, Berger E D, et al. Not So Fast: Analyzing the Performance of WebAssembly vs. Native Code[C]//2019 USENIX Annual Technical Conference (USENIX ATC 19). 2019: 107-120.

  2. Haas A, Rossberg A, Schuff D L, et al. Bringing the web up to speed with WebAssembly[C]//Proceedings of the 38th ACM SIGPLAN Conference on Programming Language Design and Implementation. 2017: 185-200.

  3. Clark L. What makes WebAssembly fast?[EB/OL]. Mozilla Hacks, 2017.

  4. Figma. WebAssembly cut Figma’s load time by 3x[EB/OL]. Figma Blog, 2017.

  5. V8 Team. A new way to bring garbage collected programming languages to WebAssembly[EB/OL]. V8 Blog, 2023.

  6. qouteall. WebAssembly Limitations[EB/OL]. qouteall notes, 2025.

  7. 00f.net. Performance of WebAssembly runtimes in 2023[EB/OL]. 2023.

  8. WebAssembly Community Group. WebAssembly Specification[EB/OL]. webassembly.org.

  9. USENIX Association. Browsix: Bridging the Gap Between Unix and the Browser[C]//ASPLOS ‘17.