跳到主要内容
浏览器
导航
内核与技术专题

WebAssembly 执行模型

介绍 WebAssembly 的执行模型:栈式指令集与线性内存、加载期静态校验、解释执行与分层编译路径、表与间接调用、与 JavaScript 的互操作,以及引用类型、垃圾回收与线程原子操作等提案的进展。

发布于 5 分钟阅读 1888 字

WebAssembly(简称 Wasm)是一种可移植的二进制指令格式,它以模块为分发单位,被设计成可由浏览器加载并执行的编译目标。它与 JavaScript 并存在同一运行环境中,通过显式的导入与导出接口互相调用。

它的执行模型有几个显著特征:指令围绕栈式虚拟机组织,代码只有在类型静态校验通过后才能运行,可写数据集中在一段线性内存中。这些约束使它可以在不信任代码来源的前提下,明确划定其可访问的边界。

栈式虚拟机与指令集

Wasm 的指令集面向栈式虚拟机:指令从操作数栈弹出参数、压入结果。函数调用拥有自己的栈帧,局部变量存放在帧内而不是操作数栈上。

(module
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add)
  (export "add" (func $add)))

上面的模块导出一个接收两个 i32 并返回 i32 的函数。local.get 把参数压栈,i32.add 弹出两个值并压入结果。数值类型有 i32、i64、f32、f64,另有 v128 用于单指令多数据运算。

栈式结构的好处是二进制编码紧凑:指令无需编码操作数位置,只需按顺序排列。多数引擎会在执行前把它转换为寄存器式表示,以减少出入栈搬运。

线性内存与隔离

每个模块实例可以拥有一段或多段线性内存,它是一块连续的字节数组,由 memory.grow 按页扩展,每页通常为 64 KiB。读写通过带立即数偏移的加载与存储指令完成:

(module
  (memory 1)
  (data (i32.const 0) "hello")
  (func $read (param $p i32) (result i32)
    i32.load8_u offset=0 (local.get $p)))

线性内存的特点是:所有访问都经过显式的地址计算,引擎可以在编译时插入边界检查,也可以在保持语义的前提下把检查优化掉。模块无法直接访问宿主进程的地址空间,只能访问自己那段内存,这构成了一道轻量级的隔离边界。

在 JavaScript 侧,线性内存以 ArrayBuffer 的形式暴露,因此两侧可以共享同一块字节区域,避免数据复制。共享内存(shared memory)是另一种形式,它允许在多个线程之间共同读写,并配套提供原子指令。

类型系统与静态校验

模块在加载时会被逐条校验:指令的操作数类型必须与栈上的类型匹配,控制流的目标必须与当前栈高度一致,跳转不能越出函数体。校验通过的模块才允许实例化。

校验项检查内容
类型匹配每条指令的入参、出参类型
栈高度分支目标处的操作数栈深度一致
索引范围函数、表、内存、全局量索引不越界
结构完整各段之间的依赖顺序与长度合法

因为类型在加载期已经确定,执行期的动态类型检查可以大幅减少。这也是 Wasm 能被编译为直接使用机器寄存器的机器码的前提。

编译路径与执行

引擎加载模块后可以选择解释执行或编译执行。V8 中的路径是先由 Liftoff 基线编译器快速生成机器码,再由 TurboFan 对热点函数做进一步优化;其它实现也存在类似的解释器加分层编译组合,这与 脚本引擎的分层编译 在结构上相通。

const resp = await fetch('/module.wasm');
const bytes = await resp.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, imports);
console.log(instance.exports.add(1, 2));

因为模块在编译前已完成校验,编译器无需处理类型错误与非法跳转,只需关注代码生成。脚本语言需要在执行中收集类型反馈,而 Wasm 的类型信息在加载时即已齐备。

表与间接调用

函数指针在 Wasm 中通过表来实现。表是一段存放函数引用的数组,call_indirect 指令按照表下标调用其中的函数,并携带一个预期的函数类型。

(module
  (table 2 funcref)
  (type $binop (func (param i32 i32) (result i32)))
  (elem (i32.const 0) $add)
  (func $call (param $idx i32) (result i32)
    (call_indirect (type $binop) (local.get $idx))))

运行时引擎会检查下标是否越界,以及被调用函数的类型是否与指令声明一致,两项检查都不通过则引发陷阱。这套机制让 Wasm 的间接调用具备类型安全性,代价是每次调用都有一次校验,多数引擎会用内联缓存来减少重复校验的开销。

与 JavaScript 的互操作

JavaScript 通过 WebAssembly.instantiate 加载模块,并传入导入对象;模块导出的函数、内存与表会出现在 instance.exports 上。两侧的边界是显式的:

  • JavaScript 的数值类型与 Wasm 的数值类型按固定规则对应,i64 在 JavaScript 侧以 BigInt 表示。
  • 字符串不直接支持,需要写入线性内存并传递指针与长度。

从 Wasm 调用 JavaScript 需要经过导入函数,每次跨界都有一定的开销,因此高频的小调用通常会被批量化,或改为在内存中交换数据后再一次性处理。

引用类型与垃圾回收

早期版本的 Wasm 只支持数值类型,若要引用宿主对象,需要借助整数句柄与表。引用类型提案引入了 externref 与 funcref,使模块可以直接持有宿主对象的引用,且这些引用由宿主环境的垃圾回收器管理。

垃圾回收提案在此基础上加入结构体、数组、子类型与 struct.new 等指令,使模块内部可以分配受管对象并与 JavaScript 侧共享。这部分的语义仍在标准化推进过程中,不同实现的支持范围并不相同。

线程与原子操作

线程提案允许同一模块实例被多个线程执行,前提是其内存被声明为共享。共享内存上只能使用原子指令进行访问,如 i32.atomic.load、i32.atomic.rmw.add,非原子访问与原子访问不能混用于同一地址。

线程同步依赖 memory.atomic.wait 与 memory.atomic.notify:前者在值未改变时挂起当前线程,后者唤醒等待者。这套原语足以构建互斥量与条件变量,但等待操作在浏览器主线程上受到限制,以避免阻塞渲染。

确定性执行

Wasm 的计算语义在规范中定义得较为严格:整数运算按固定位宽回绕,浮点按 IEEE 754 规定执行,地址计算方式明确。因此相同输入在相同模块上通常给出相同结果。

不确定性的来源主要落在宿主环境:线性内存的初始地址、多线程下的执行顺序、调用宿主函数产生的副作用。若模块不使用共享内存与宿主调用,其行为便是可复现的,这一特性使 Wasm 适合作为跨平台的执行目标。

小结

Wasm 的执行模型围绕三点展开:栈式指令集带来紧凑的二进制表示,加载期静态校验确保类型安全,线性内存划定访问边界。在此之上,表支持间接调用,导入导出接口负责与宿主互通,线程提案提供共享内存与原子操作,引用类型与垃圾回收提案则在扩展它与宿主对象的交互方式。

内核与技术专题 · 相关文章