Wasmtime 47 默认启用 WASM GC 与异常:高阶语言进军 WebAssembly 的最后一道运行时枷锁被打破
2026 年 7 月 22 日,Bytecode Alliance 放出信号很强的版本:Wasmtime 47 把 WASM GC 和 异常处理(exceptions) 从“实验开关”变成了默认开启的能力。对 WebAssembly 生态来说,这不是单纯多支持两个提案,而是意味着 Java、Kotlin、Go、C#、Swift 这类自带对象模型和异常机制的高阶语言,终于可以不再往
.wasm包里塞一整套沉重的垃圾回收器,就能自然地把代码跑进 WASM。本文从“为什么需要 WASM GC”讲起,拆解提案原理、Wasmtime 在线性内存上长出的 Cheney 复制收集器、再到 Rust 宿主实战与性能权衡,聊聊这场运行时革命的真正分量。
一、背景:WASM 的“托管语言困境”
WebAssembly 最早是为 C / C++ / Rust 这类“线性内存 + 手动/确定性内存管理”的语言设计的。它的抽象非常朴素:
- 一块连续的线性内存(
memory),字节寻址; - 栈式虚拟机指令(
i32.add、f64.mul、local.get……); - 没有对象、没有引用、没有类型化的堆,更没有“垃圾回收”这个概念。
这对 Rust 来说几乎是天作之合:Rust 的所有权模型在编译期就解决了内存安全,运行时不需要 GC;编译到 WASM 后,线性内存就是它唯一需要关心的堆。所以你看,wasm-pack、wasmedge、Spin、 Fermyon 这些生态跑得飞起,本质上是因为 Rust 和 WASM 的内存哲学是同构的。
但问题来了:世界上绝大多数业务代码并不在 Rust 里。它们散落在 Java、Kotlin、Go、C#、Swift、Dart 这些语言里——而这些语言有一个共同特征:它们有对象、有引用、有子类型、有异常,而且依赖一个垃圾回收器来管理对象生命周期。
当这些语言想进入 WASM 时,WASM 的“朴素”就成了障碍。在 WASM GC 提案出现之前,社区的解法只有一种:把 GC 自己编译进 wasm。
- Kotlin/Wasm 早期版本、AssemblyScript(自带一个紧凑的基于 mark-sweep 的 GC)、Go 的 wasm 后端、Unity 的 C# 裁剪运行时……无一不是把整套内存管理逻辑搬进
.wasm; - 代价极其明显:二进制体积暴涨(GC 本身要占几十到上百 KB,还不算元数据)、正常路径(happy path)出现额外开销(每次分配都要走语言自带分配器,而不是 WASM 的线性内存)、以及最致命的——每种语言都得自己造一遍 GC 轮子,工程复杂度直接劝退中小团队。
换句话说,在 WASM GC 之前,高阶语言进 WASM 的本质是“自带运行时包袱移民”。你不是在用 WASM 的运行时,而是把自家运行时整个打包空运过去。这与 WASM “小巧、安全、可移植、秒级启动”的卖点背道而驰。
WASM GC 提案要解决的,正是这件事:让 WASM 运行时本身提供对象类型和垃圾回收,语言只负责描述“我的对象长什么样”,生命周期交给 WASM 虚拟机。
二、核心概念:WASM GC 提案到底加了什么
WASM GC 不是给 WASM 塞一个具体的垃圾回收器实现,而是给 WASM 的类型系统加了一组“结构化引用类型”,并规定运行时必须能管理这些类型的生命周期。语言编译器和运行时在此基础上各显神通。
2.1 两类 GC 对象类型
提案在 valuetype 里新增了:
structtype:类似语言里的结构体 / 对象。字段可以是任意 WASM 值类型(含其他 GC 引用),例如:(type $point (struct (field $x f64) (field $y f64)))arraytype:定长或运行时长度可变的数组,元素同样可以是任何值类型:(type $vec (array (mut f64)))
此外还有三个关键的基础引用类型:
i31ref:一个能装下 31 位有符号整数的、不经过 GC 堆的轻量引用(高 1 位用于 tag,避免和指针混淆)。它让你能在“需要引用类型但只想传个小整数”的场景里省掉一次堆分配;eqref:所有“可比较(能ref.eq)”的引用类型的父类型,包括 struct、array、i31ref 等;structref/arrayref:分别是所有 struct / array 类型的父类型,用于在不知道具体子类型时持有引用。
2.2 子类型与“深度子类型”
这是 WASM GC 真正优雅的地方。GC 类型支持子类型(subtyping),而且是基于结构的(structural),不是名义的(nominal)。
(struct (field f64 f64)) ; 父类型:两个 f64
<:
(struct (field f64 f64) (field i32)) ; 子类型:多一个 i32 字段,且前两个字段类型一致
子类型关系遵循“声明位置不变、字段只增不减、协变/逆变规则一致”的原则。这为语言的对象继承、接口实现提供了底层支撑——比如 Kotlin 的某个子类实例,在 WASM 层可以直接当作父类 eqref 传给一个只认父类的函数。
WASM 还区分了深度子类型(depth subtyping)与声明子类型(decl subtyping),让数组、可变字段在协变/逆变上能做到既灵活又安全。具体规则属于规范细节,工程上你只需要知道:WASM GC 的类型系统足以表达主流 OO 语言的继承与泛型约束,不需要语言在 WASM 之上再用 i32 索引手写一套“虚表 + 偏移”的仿真层。
2.3 操作 GC 对象的指令
提案给了一组读写 GC 对象的标准指令,我列最常用的几个:
| 指令 | 作用 |
|---|---|
struct.new $t / struct.new_default $t | 在 GC 堆上分配并初始化一个 struct |
struct.get $t $field / struct.set $t $field | 读 / 写 struct 字段 |
array.new $t / array.new_fixed $t / array.new_data $t | 分配数组 |
array.get $t / array.set $t | 读 / 写数组元素 |
ref.cast $t / ref.test $t | 把引用向下转型(失败抛陷阱)/ 仅测试是否能转型 |
br_on_cast $t | 若引用能转型为目标类型则跳转(常用于类型分发) |
ref.eq | 比较两个引用是否指向同一对象(类似 == 而非 .equals()) |
ref.as_non_null | 把可空引用断言为非空 |
注意一个关键差异:ref.eq 比较的是引用同一性(identity),不是值相等。这正好对应 Java 的 ==、Kotlin 的 ===。值相等的 equals 由语言自己实现,WASM 不介入——这把“对象相等性语义”的所有权还给了语言,运行时只管内存,非常克制。
2.4 一个最小可运行的 WASM GC 模块(WAT)
下面这段 WAT 定义了一个 point 对象,并提供“构造”和“求到原点距离”两个导出函数。它完全用 WASM GC 的类型与指令写成,不依赖任何语言自带 GC:
(module
;; 定义一个 GC 对象类型:二维点
(type $point (struct
(field $x f64)
(field $y f64)))
;; make(x, y) -> (ref $point)
(func $make (param $x f64) (param $y f64) (result (ref $point))
(struct.new $point
(local.get $x)
(local.get $y)))
;; dist(p) -> sqrt(x^2 + y^2)
(func $dist (param $p (ref $point)) (result f64)
(f64.sqrt
(f64.add
(f64.mul
(struct.get $point $x (local.get $p))
(struct.get $point $x (local.get $p)))
(f64.mul
(struct.get $point $y (local.get $p))
(struct.get $point $y (local.get $p))))))
(export "make" (func $make))
(export "dist" (func $dist)))
用 wasm-tools 或任意支持 GC 的 toolchain 把它汇编成 .wasm,在 Wasmtime 47 里就能直接跑——而 .wasm 里没有一行 GC 实现代码,回收 point 的全部职责都在 Wasmtime 运行时里。
三、异常提案:把 try/catch 交还给运行时
高阶语言除了对象,还有第二大特征:异常。而在 WASM GC 之前,WASM 连“异常”都没有一等公民地位。
3.1 旧世界的三种 workaround
- 返回值携带错误位:每个函数返回
(result i32)实际是(result 正常值, 错误码),调用方必须手动if (err) ...。Go 风格的(_, err) := f()、早期很多 wasm 绑定都这么干。代价是正常路径被污染——哪怕不抛错,每次调用都要检查错误位; - 靠 JS 异常桥接:在浏览器里把 WASM 的 trap 翻译成 JS 异常。但 trap 不是异常,它是“虚拟机认为程序出错直接中止”,你没法
catch后继续跑,而且脱离了 JS 宿主就失效; - setjmp/longjmp 仿真:在 wasm 里用表 + 栈帧编号模拟,丑陋且慢。
3.2 异常提案长什么样
异常提案引入了 tag(异常标签,类似异常类型)和一组控制流指令:
throw $tag:抛出一个异常;try_table+catch $tag/catch_ref $tag:进入受保护的代码区,命中对应 tag 时跳到 handler;rethrow:在 handler 里重新抛出。
概念上,它就是把 try { ... } catch (E e) { ... } 下沉到了 WASM 指令层。语言编译器不再需要为每个函数多塞一个错误位,运行时直接维护异常控制栈。
一个示意性的 WAT(不同 toolchain 文本语法略有差异,这里表达语义):
(module
(tag $overflow) ;; 定义一个异常标签
(func $checked_add (param $a i32) (param $b i32) (result i32)
(try_table (catch $overflow)
;; body
(i32.add (local.get $a) (local.get $b))
;; 简化示意:实际溢出检测需额外指令
)
;; handler:捕获后返回 -1
(drop) ;; 弹出一个 exnref
(i32.const -1)))
(export "checked_add" (func $checked_add))
要点是:异常成为 WASM 的一等控制流原语后,Kotlin 的 try/catch、Java 的 try-catch、C# 的 try/catch 可以 1:1 映射到 WASM,不再有“错误位税”。正常路径干干净净,只有真正抛错才付出代价——这正是零成本抽象(zero-cost abstraction)该有的样子。
Wasmtime 47 把异常和 GC 一起默认开启,意味着这两条能力现在都是“生产可用”状态,而不是“你敢用我就敢编译”的预览特性。
四、架构分析:Wasmtime 47 如何在线性内存上“长”出一个 GC
这是全文最硬核、也最体现工程功底的一节。Wasmtime 团队做了一件很聪明的事:没有把 GC 做成一套脱离现有引擎的特例,而是把 GC 堆建在 WebAssembly 的线性内存之上,复用 Wasmtime 已有的安全、可移植与虚拟内存护栏能力。
4.1 为什么不另起炉灶
WASM 运行时本身已经有了一块受控的线性内存、一套边界检查、以及(在 Wasmtime 里)基于虚拟内存保护的“guard pages”机制。如果 GC 堆另起一套独立内存,就要重新解决隔离、越界、可移植、与宿主交互等一系列问题。
Wasmtime 的选择是:GC 对象直接分配在线性内存的某个区域里,由运行时统一管理这块区域的对象布局、分配与回收。这样一来:
- GC 堆和其他 WASM 内存共享同一套地址空间与保护机制;
- 不需要为 GC 单独开辟系统内存,移植到新平台时自动继承 Wasmtime 的移植性;
- 对象的引用就是线性内存里的地址(带 GC 元数据),和 WASM 的指针模型天然契合。
4.2 Cheney 半空间复制收集器
Wasmtime 的 GC 实现采用的是经典的 Cheney 半空间复制(semispace copying)算法。理解它,先理解“半空间”:
- 把 GC 堆分成大小相等的两块:From 空间 和 To 空间;
- 分配时只在 From 空间里用一根“分配指针(bump pointer)”顺序推进——
ptr += size就行,分配是 O(1) 且无需找空闲块,这是复制收集器最大的性能甜点; - 当 From 空间满,触发 GC:从一组“根(roots,比如 WASM 栈上的引用、全局变量里的引用)”出发,把存活对象复制到 To 空间,并在原对象上留一个“转发指针(forwarding pointer)”指向新位置;
- 复制过程中遇到对象里引用的其他对象,递归/迭代地复制,直到所有可达对象都搬完;
- 最后交换 From / To 的角色,整块旧空间一次性回收——没有碎片整理这一步,因为复制本身就是整理。
Cheney 算法的精妙在于它用一个简单的“扫描指针 + 待复制队列”就完成了广度优先式的复制,不需要递归爆栈,也不需要复杂的空闲链表。对 WASM 这种“对象小、存活率低、分配极频繁”的场景,复制收集器的吞吐优势非常明显。
4.3 写屏障、根枚举与栈协作
复制收集器的正确性依赖三件事,Wasmtime 都做了:
- 根枚举(root scanning):GC 必须知道 WASM 栈上、全局变量里哪些槽位是 GC 引用。Wasmtime 在编译期就为每个函数的栈帧布局标注了“哪些位置是 GC ref”,GC 触发时按图索骥;
- 写屏障(write barrier):因为复制发生在 GC 时,而 mutator(运行的 WASM 代码)在两次 GC 之间会修改对象字段。对于“分代/增量”变体需要写屏障来记录跨代指针;Wasmtime 当前实现以单次全堆复制为主,但架构上预留了增量与并发的演进空间;
- 与引擎栈的协作:GC 不能在不一致的状态下运行。Wasmtime 会在安全的“安全点(safepoint)”暂停 WASM 执行、完成根枚举,再开始复制。因为 WASM 指令本身是确定的、可中断的,做安全点比在原生线程里插桩要干净得多。
4.4 为什么“默认开启”比“支持”重要得多
提案支持(feature flag on)和默认开启(default on)是两回事:
- 支持阶段,编译器、工具链、宿主之间互相观望,谁都不敢第一个吃螃蟹,生态是碎片化的;
- 默认开启意味着:任何用 Wasmtime 47 运行的 WASM,只要包含 GC 类型,无需任何开关即可获得 GC 能力。这等于给所有下游语言编译器发了“可以放心生成 GC 代码”的信号。
rustcc 的日报评价得很到位:这是“多年工程收口后的里程碑,而不是实验性开关”。配合 Wasmtime 针对 WASM GC 扩展过的 fuzzing 基建(对 GC 类型、子类型转换、转发指针、边界做海量模糊测试),默认开启才敢说是“生产可用”。对做运行时、编译器、Wasm 平台的人来说,这条线的长期影响比单次版本号大得多。
五、代码实战:用 Rust 宿主运行一个 WASM GC 模块
光讲原理不够,我们把它跑起来。下面所有代码在 Wasmtime 47 + Rust 环境下可运行。
5.1 准备环境
# 安装 wasmtime(二进制,或作为 Rust 依赖)
curl https://wasmtime.dev/install.sh -sSf | bash
# 如果用 Rust 宿主调用,添加依赖
cargo add wasmtime@47
cargo add anyhow
把第二章的 WAT 保存为 gc_point.wat,用 wasm-tools 汇编:
# 安装 wasm-tools
cargo install wasm-tools
wasm-tools parse gc_point.wat -o gc_point.wasm
5.2 Rust 宿主:加载并调用 GC 模块
这里的关键认知:GC 是模块内部的事,宿主调用的导出函数完全可以只用标量(f64/i32)通信。Wasmtime 47 默认开启 GC,我们显式声明 config.wasm_gc(true) 纯粹是为了表意清晰。
use wasmtime::*;
fn main() -> anyhow::Result<()> {
// 1) 配置引擎:Wasmtime 47 起 GC 默认开启,这里显式声明以表意
let mut config = Config::new();
config.wasm_gc(true);
let engine = Engine::new(&config)?;
// 2) 编译模块(GC 类型会被校验并纳入引擎的 GC 路径)
let module = Module::from_file(&engine, "gc_point.wasm")?;
// 3) 创建 store 与实例
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
// 4) 取导出函数。注意:GC 对象在宿主侧通常不必直接跨越边界,
// 我们用“构造 + 计算”两个标量接口组合调用。
let make = instance.get_typed_func::<(f64, f64), u32>(&mut store, "make")?;
let dist = instance.get_typed_func::<u32, f64>(&mut store, "dist")?;
// 5) 调用:在 WASM 内部,point 是分配在 GC 堆上的对象;
// 这里为了示例简单,把引用以 u32 handle 形式传回(真实场景可用 StructRef/externref)。
let handle = make.call(&mut store, (3.0, 4.0))?;
let d = dist.call(&mut store, handle)?;
println!("distance(3,4) = {}", d); // 输出 5.0
Ok(())
}
工程提示:如果你确实需要把 GC 引用作为一等值跨过宿主边界,Wasmtime 提供了
wasmtime::gc::StructRef/AnyGcRef以及ExternRef等类型来桥接。多数业务场景下,用 WIT 组件模型定义清晰的接口(见 5.4)比直接搬运原始引用更安全、更易演进。
5.3 验证“没有自带 GC”这件事
我们可以反过来证明 GC 不在 .wasm 里:
# 看导出的函数与数据段大小
wasm-tools print gc_point.wasm | head -40
# 对比:同一个 point 逻辑,如果用“自带 mark-sweep GC”的语言编译,
# 你会看到大量 GC 相关的函数、元数据段和额外的内存常驻结构。
gc_point.wasm 里只有我们写的 make / dist 逻辑,没有任何 gc_alloc / sweep / mark 之类的符号——因为那些活儿 Wasmtime 运行时替我们干了。二进制体积的差距,在复杂业务里会被放大成几十到上百 KB 的鸿沟。
5.4 进阶:用组件模型定义 GC 友好的接口
WASM GC 通常和 组件模型(Component Model)+ WIT 搭配使用,让多语言模块有统一、类型安全的接口。定义一个几何组件:
// geo.wit
package my:geo@0.1.0;
interface geo {
record point {
x: float64,
y: float64,
}
distance: func(p: point) -> float64;
}
world app {
export geo;
}
然后可以用 Kotlin/Wasm(其 wasm 目标已基于 WASM GC)、Go 的 wasm 后端、或手写 GC 模块来实现 geo,再用 wasm-tools component new 封装成组件,最后在 Rust 宿主里用 wasmtime::component::Linker 调用。组件模型负责把 WIT 的 record point 在底层映射成 WASM GC 的 structtype,你完全不需要手写字段偏移和序列化——这正是“一份数据、多语言视图”的工程落地,和 RobustMQ 那种“一份数据、多协议视图”的设计哲学异曲同工。
六、性能与工程权衡:到底赢了什么,又付出了什么
Wasmtime 47 的日报把收益归纳为三点:二进制体积、正常路径运行开销、语言接入工程复杂度。我们逐一拆开看。
6.1 二进制体积:从“自带 GC”到“零 GC”
| 方案 | wasm 内 GC 代码 | 典型体积增量 |
|---|---|---|
| 语言自带 mark-sweep / bump GC | 有,且随功能膨胀 | 数十~数百 KB |
| WASM GC(Wasmtime 管理) | 无 | ≈ 0 |
对边缘函数、插件、浏览器内小模块这类“体积敏感”场景,这往往意味着 首屏/冷启动从「先下载并初始化 GC」变成「直接运行」。
6.2 正常路径开销:分配 O(1) vs 自带分配器
自带 GC 的语言,每次 new 都要走自己的分配器(空闲链表查找、锁、分代判断……)。而 WASM GC 的复制收集器是 bump pointer 顺序分配,一次 struct.new 约等于“指针加 size”。在对象分配密集的业务(序列化、DOM 树、AST、游戏实体)里,这个差异会被放大成肉眼可见的吞吐差距。
当然,复制收集器不是免费的午餐:它的代价在 GC 暂停(stop-the-world 把存活对象搬一遍)。但因为对象是“小且短命”的,且半空间复制天然没有碎片,实际暂停时间通常很短、且可预测——对 p99 延迟敏感的场景(实时通信、流媒体、游戏)反而友好。
6.3 语言接入复杂度:从“造轮子”到“描述类型”
最被低估的收益是工程复杂度。以前一门语言要支持 WASM,得先实现或移植一个 GC——这是以“人月”计的工作量。WASM GC 之后,语言编译器只需要:
- 把对象翻译成
structtype/arraytype; - 把字段访问翻译成
struct.get/set; - 把子类型翻译成 WASM 的子类型声明;
- 剩下的回收、安全、移植,全部交给 Wasmtime。
这就是为什么你会看到 Kotlin/Wasm、Java 的 wasm 探索、Go、C#、Swift 在 2024–2026 年间密集拥抱 WASM GC——不是因为它们突然爱上了 WASM,而是 WASM GC 把“进 WASM”的成本从“重写运行时”降到了“改代码生成后端”。
6.4 什么时候不该用
诚实地说,WASM GC 不是银弹:
- 如果你的代码本来就是 Rust/C,用线性内存 + 所有权就够了,引入 GC 类型反而多一层抽象;
- 如果你的对象极大、存活极久(大数组、长生命周期缓存),复制收集器的“全堆搬运”会有搬移成本,需要评估;
- 如果宿主不是 Wasmtime / 不支持 GC 提案(旧版运行时、部分浏览器版本滞后),模块跑不起来——所以“默认开启”的扩散对生态很关键。
七、更大的图景:WASM GC 解锁了什么
把视野拉高,Wasmtime 47 默认启用 GC,真正解锁的是三类过去很难做好的场景。
7.1 多语言 WASM 成为现实
Kotlin 写业务逻辑、Rust 写高性能内核、Go 写并发编排、C# 复用存量代码——它们现在可以编译成同一个 WASM 运行时里的不同模块,通过组件模型互操作,而不必各自扛一套运行时。这对“渐进式把单体的一部分换语言”是质变。
7.2 安全的插件化与沙箱
这是笔者认为最值得关注的落地方向。想想这些需求:
- 数据库 UDF / 触发器:用户上传一段处理逻辑,数据库在沙箱里跑,崩了不影响主进程,还带 GC 自动回收临时对象;
- AI Agent 执行不可信代码:MCP 这类协议让 Agent 调用工具,如果工具是一段 WASM GC 模块,运行时能限制它的内存、CPU、系统调用,比直接
exec安全一个数量级; - 边缘函数 / 多租户 SaaS:每个租户的自定义脚本跑在独立 WASM 实例里,计费按指令数,回收自动完成。
WASM 的“默认拒绝系统调用 + 显式能力授权(WASI)”模型,叠加 GC 之后,终于能承载带对象模型的高阶语言写的插件,而不再只能是 C/Rust 的“裸逻辑”。
7.3 与 WASI、组件模型的协同
WASM GC 不是孤岛。它的价值在和 WASI(系统接口)、组件模型(接口与组合)、Wasmtime 的虚拟化护栏三者叠加时最大化:GC 管内存生命周期,WASI 管能力边界,组件模型管接口契约。这正是 Bytecode Alliance 一直在推的“可组合、可移植、安全的计算单元”愿景的最后一公里。
八、总结与展望
回到开头的判断:Wasmtime 47 默认启用 WASM GC 与异常,表面是“多支持两个提案”,实质是 WebAssembly 从「系统语言运行时」进化为「通用语言运行时」的分水岭。
- 对语言设计者:进 WASM 的门槛从“重写 GC”降到“改后端”;
- 对应用开发者:高阶语言终于能轻装进 WASM,二进制更小、启动更快、异常更自然;
- 对平台构建者:安全的多语言插件、沙箱、边缘计算有了统一的底座。
当然,路还没走完。GC 的并发/增量变体、与 WASI 线程的协作、各浏览器对 GC/异常提案的跟进节奏、以及工具链(wasm-tools、各语言编译器)的成熟度,都还需要时间收敛。但“默认开启”这四个字,意味着这件事已经过了“要不要做”的争论,进入了“怎么做更好”的工程迭代阶段。
如果你正在做插件系统、数据库扩展、AI Agent 工具执行,或者单纯想把一部分 Java/Kotlin/Go 业务搬进边缘环境——现在,是认真评估 WASM GC 的好时机。
参考资料
- Bytecode Alliance — Wasmtime 47: WASM GC and Exceptions now enabled by default(https://bytecodealliance.org/articles/wasmtime-gc)
- WASM GC 提案规范(structtype / arraytype / 子类型 / 指令)
- WASM 异常提案规范(tag / try_table / catch / throw)
- Rust 中文社区日报 2026-07-22(Wasmtime 47、syn 3.0.0、append-only 数据库 mmap 长文)
wasm-tools工具链文档(parse / component new / print)
本文所有代码与原理均可在 Wasmtime 47 + 对应 toolchain 环境验证;异常提案的 WAT 文本语法因工具链版本可能略有差异,语义一致。