引言
C/C++ 有 try/catch、Rust 有 panic/unwind、Java 有 throw——但 WASM 最初没有异常指令。于是编译器只能把「抛异常」降级成「返回错误码 + 手动检查」,写起来痛苦、性能也差。Exceptions 提案补上这块:WASM 原生支持标签抛出与类型化捕获。而更进一步的 Stack Switching 提案 让 WASM 能「挂起整条调用栈再恢复」——这为真正的异步运行时、协程、生成器铺平道路。本文把这两大控制流提案讲透:它们解决什么、指令怎么用、运行时支持到哪、以及和 JS 事件循环怎么对接。
前置:/wasm-introduction-architecture/(执行模型)、/wasm-rust-compilation-optimization/(Rust panic/futures)、/wasm-garbage-collection/(引用与对象模型)。
目录
- 1. 为什么 WASM 需要异常
- 2. 传统方案的局限
- 3. Exceptions 提案核心
- 4. throw 与 br_on_exn
- 5. try_table 与跨宿主分派
- 6. 各语言的异常落地
- 7. 栈切换提案:suspend/resume
- 8. JSPI 与异步统一
- 9. 应用场景与成本
- 10. 速查表与一句话记忆
- 延伸阅读
1. 为什么 WASM 需要异常
异常是现代编程语言的控制流基础设施,WASM 不提供就得「自己造」:
语言的异常需求:
□ C++:try/catch/throw(RAII 与栈展开)
□ Rust:panic(默认 abort,可 unwind)、Result
□ Java/C#/Kotlin:checked exceptions(WasmGC 语言尤其依赖)
WASM 视角:
□ 异常 = 一种「非局部控制流」——从任意深度跳回处理点
□ 编译器需要表达「可能抛、在哪接住、栈怎么展开」
为什么重要:没有原生异常,编译器要么禁用异常(Rust panic=abort 就是这种妥协),要么用笨重的手动机制模拟——原生异常让语言语义直接落到 WASM,而不是「阉割版」。
2. 传统方案的局限
在 Exceptions 提案之前,编译器的选择都很别扭:
方案 1:禁用异常(panic=abort / -fno-exceptions)
→ 程序遇到错误直接终止,无法优雅处理
→ 运行时崩,不能 catch
方案 2:返回码 + 手动检查
→ 每个可能失败的函数返回「结果 + 错误」
→ 调用方必须逐层检查 → 样板代码爆炸
→ 性能:每条路径都要分支
方案 3:自己用线性内存模拟「异常表 + 长跳转」
→ 复杂、易错、难调试
→ 编译器各搞各的,无法互操作
工程影响:这些方案让「优雅错误处理」成为 WASM 代码的奢侈品。Exceptions 提案让 panic、异常、Result 都能按语言原义表达,且跨模块统一。
3. Exceptions 提案核心
Exceptions 提案(Phase 3+,主流引擎推进中)的核心概念:
核心机制:
□ 标签(tags):像「异常类型」,可携带值(tag + 负载)
□ throw:抛出「标签 + 值」→ 在调用栈中查找匹配的 catch
□ 捕获:把标签值解包,执行处理代码
□ 栈展开:抛出时跳过中间帧(类似语言的异常传播)
;; 声明异常标签
(tag $oom (param i32)) ;; 携带一个 i32(错误码)
;; 抛出
i32.const 42
throw $oom ;; 抛出 $oom 标签 + 值 42
与语言映射:
C++ throw → WASM throw + tag(运行时把异常类型映射成标签)
Rust panic → 默认 abort(无 unwind)或 unwind 用异常标签
JS → host 异常可以跨边界传给 WASM(见第 5 节)
关键认知:异常标签是类型化的(携带参数),类似「带类型载荷的异常对象」,而不是裸的错误码。这让编译器能精确匹配「接住哪种异常」。
4. throw 与 br_on_exn
捕获的关键指令是 br_on_exn——它把「异常」按标签类型匹配并分支:
;; 捕获模式:在一个块里尝试执行,异常时跳到 catch
block $try
;; ... 可能抛出的代码 ...
;; 正常路径结束
end
;; 更完整的 try_table 模式(见下节)
try_table $catch_oom
i32.const 1
call $may_throw ;; 内部可能 throw $oom
end
$catch_oom:
;; 异常被接住:栈上是异常值(i32 错误码)
;; 处理...
br_on_exn 语义:
标签匹配 → 分支到目标,栈上保留「标签值」
标签不匹配 → 继续传播(跳过)
→ 编译器用它实现「catch 按类型分流」
工程要点:异常传播是按标签精确匹配的——多个 catch 就是多个 br_on_exn 的分支。未匹配的异常继续向上传播到宿主(浏览器/运行时),可以跨 WASM 模块边界。
5. try_table 与跨宿主分派
try_table 是异常捕获的表格化语法——一条指令声明多个异常处理分支:
;; try_table:块内异常按标签分派
try_table (catch $oom $handler_oom) (catch_all $handler_any)
call $risky_op
end
;; 跳到 $handler_oom:栈上是 $oom 的载荷
$handler_oom:
;; 处理 OOM,恢复执行
$handler_any:
;; 兜底处理
跨宿主分派(host ↔ WASM):
□ JS 抛异常 → WASM 的 try_table 可以接住(类型化传递)
□ WASM throw → 宿主 JS 可以 catch(转成 JS 异常/值)
→ 异常跨边界可双向传递,不再被「卡在模块里」
工程要点:try_table 让「多个 catch 子句」编译成单条指令 + 分支表,比一串 br_on_exn 更紧凑高效。编译器(Rust/C++/assembly)生成这种形态,人一般不需要手写。
6. 各语言的异常落地
Exceptions 提案对语言生态的实际影响:
| 语言 | 现状/方向 | 说明 |
|---|---|---|
| C++ | Emscripten 已支持异常(老方案);原生 Exceptions 提案推进 | 用标签表达 catch 类型 |
| Rust | panic = "abort" 常用(体积);unwind 走异常标签 | 默认 abort 省体积 |
| Java/C#/Kotlin | WasmGC 语言强烈依赖异常 | 提案是它们的必需品 |
| AssemblyScript | 原生支持 try/catch 映射 | 直接映射到提案 |
Rust 的两条路:
□ panic = "abort":异常直接 trap,体积小、简单
□ panic = "unwind":用异常标签实现 unwinding(提案支持后)
→ 可 catch_unwind,但体积/性能有代价
工程要点:语言侧仍在适配——生产代码可先用 panic=abort/禁用异常保体积稳定;等提案 + 工具链成熟后,再开 unwind 以支持优雅错误处理。要看运行时支持矩阵再决定(见第 7 节相关)。
7. 栈切换提案:suspend/resume
比异常更进一步的是栈切换(Stack Switching)——让 WASM 能挂起和恢复整条调用栈:
核心指令:
suspend:挂起当前栈,跳到 host 提供的 continuation(记录恢复点)
resume:从保存的恢复点继续执行(栈被恢复)
模型:
□ 挂起点 = 一个「恢复点」,保存调用栈状态
□ 挂起时可以「换到另一条栈」(coroutine 切换)
□ 栈迁移:把当前栈复制/移动到新栈 → 真正的协程
关键提案(Phase 2+):swift / suspend / resume,配合:
□ stack guards(栈边界保护)
□ 作用域(scoped continuations,限制恢复次数)
;; 伪示意:suspend 到 host 的入口
(func $yield (param i32)
suspend $entry ;; 挂起,栈上带上 payload 给 host
;; host resume 后从这里继续
)
与异常的区别:异常是「一次性、向上展开」;栈切换是「可恢复、双向跳转」——协程/生成器/async 的基础。未来 Async 运行时可以真正用「同步风格的 WASM 代码」做异步 I/O。
8. JSPI 与异步统一
在栈切换提案落地前,浏览器已有过渡方案:JSPI(JavaScript Promise Integration):
JSPI 做什么:
□ 允许同步 WASM 函数调用「会返回 Promise 的 JS 函数」
□ WASM 侧看起来是同步返回(内部挂起、等 Promise、再恢复)
□ 消除「async 传染」:不用把整个调用链改成 async
原理:由 JS 引擎(V8)自动做挂起/恢复的桥接
→ 编译期/运行期的「隐式栈切换」
// JSPI(示意):同步调用异步 fetch
const fn = WebAssembly.activateSuspending(fetchFn); // 包装
const result = fn(); // 同步返回,内部等 Promise
与栈切换提案的关系:
□ JSPI = 面向 JS 的先行实现(engine 内建)
□ Stack Switching = 通用的底层能力(编译器/运行时自由使用)
→ JSPI 可视为「栈切换的一个特定用法」
工程要点:JSPI 让「WASM 里写同步代码、背后是异步 I/O」成为现实——同步风格代码 + 异步执行,避免 async 的样板传染。浏览器与独立运行时都在推进底层栈切换,成熟后语言编译器可原生生成协程。
9. 应用场景与成本
栈切换与异常的落地场景与代价:
应用场景:
□ 异步 I/O:同步风格读写,I/O 等待时挂起、事件到达恢复
□ 生成器/迭代器:next() 挂起、yield 恢复
□ 协作式多任务:一个线程里调度多个「协程」,共享栈池
□ 事件循环集成:WASM 协程与 JS 事件循环互相唤醒
□ 游戏/状态机:可恢复的状态转移
成本与限制:
□ 栈迁移:复制/切换栈有开销(栈越大越贵)
□ 栈大小:挂起栈要保留,内存占用增加
□ 需要显式标记:编译器必须知道「哪个调用可能挂起」(否则栈不能迁移)
□ 与线程模型交互:多线程 + 栈切换的组合仍在演进(见 /wasm-multithreading-sharedarraybuffer/)
工程要点:栈切换是「重型但强大的工具」——只为真正需要「挂起/恢复」的控制流用(I/O 密集型、生成器),普通 CPU 计算用同步栈就够。它让「WASM 写的服务器代码」能像传统语言一样表达异步,是服务器端 WASM 的关键基础设施。
10. 速查表与一句话记忆
| 问题 | 一句话答案 |
|---|---|
| 异常提案解决什么 | 语言原生 try/catch/panic 落到 WASM |
| 怎么抛出 | throw + 类型化标签(携带载荷) |
| 怎么捕获 | br_on_exn / try_table 按类型分派 |
| 未捕获去哪 | 跨边界传播给宿主 |
| 栈切换是什么 | suspend/resume 挂起并恢复整条调用栈 |
| 和 JSPI 的关系 | JSPI 是 JS 先行实现,栈切换是通用底层 |
| 典型用途 | async I/O、生成器、协程、事件循环 |
一句话记忆:WASM 控制流 = 异常(标签 throw + try_table 捕获,语言语义落地)+ 栈切换(suspend/resume,协程/async 底层)+ JSPI(JS 先行桥接)——让 WASM 从「纯同步过程」走向「完整控制流语言」。
延伸阅读
- /wasm-rust-compilation-optimization/ — Rust panic 与 futures 桥接
- /wasm-garbage-collection/ — 引用类型与对象模型(GC 与栈根)
- /wasm-wasmtime-runtime/ — 运行时对异常/栈切换的支持
- /wasm-component-model-wit/ — 跨语言接口与异常互操作
- /wasm-multithreading-sharedarraybuffer/ — 多线程与同步原语
- /wasm-wasi-preview2-http/ — async I/O 与网络接口
- Node.js 专题 — 事件循环与异步模型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。