Rust/Wasm 运行时的可靠性需要同时处理 panic 与 abort 恢复
共享 Wasm 实例一旦开始长期承接调用,崩溃就会从单次失败升级成状态恢复和故障隔离问题
Wasm 一开始很容易被当成一种移植层:代码能编过去,页面能跑起来,性能也还行,事情似乎就差不多了。真正开始变难,通常是在 demo 通过之后。编辑器、渲染器、文档解析器这类模块一旦从单次页面实验走进长期驻留的运行时,故障模型马上就换了。
这时候 panic 和 abort 已经不是语言层里的一次异常分支。它们决定的是:这一份实例还能不能继续接后面的活,内存里的状态有没有被污染,宿主层该不该立刻丢弃实例,实例池是不是需要补位。移动端团队把一段长期跑在原生容器里的内核搬到 Web 时,最容易低估的就是这一层变化。
Demo 通过之后,故障模型才刚开始
单次调用里的崩溃并不难理解。一次按钮点击触发一次 Wasm 调用,失败了就把这次操作报错,刷新页面再试,成本还算可控。
问题出在运行时开始复用实例之后。同一个 Wasm 实例连续打开多份文档、承接多轮输入事件、穿过多个 JS 桥接调用,panic 和 abort 的影响范围就不再停在当前动作。一次没有收干净的失败,可能把后面的请求一起拖下水。
这类风险往往不是第一天暴露。第一阶段通常只会看到零散报错:偶发渲染失败、某次导出卡死、某个文档关掉再打开之后状态不对。再往后查,线索会慢慢收敛到同一类现象:失败虽然发生在一条调用链上,损坏却留在了共享实例里。
到了这里,讨论重点已经不再是“Rust 代码会不会 panic”,而是“panic 之后这一份运行时还有没有资格继续服务下一次调用”。
panic 能接住,abort 只能换实例
Rust/Wasm 里最需要先分开的,就是 panic 和 abort 这两种失败语义。
panic 还有机会沿着既定边界 unwind 回来。只要绑定层和宿主层提前约定好恢复方式,当前调用可以失败,实例里的其它状态也有机会继续保住。abort 就完全不是这个路子。它意味着当前执行已经走到了不可恢复的状态,继续拿同一个实例接请求,本质上是在赌内存和资源没有被半途写坏。
运行时一旦把这两者混在一起,后面的处理就一定会出问题:
- 把 abort 当普通异常吞掉,实例池会继续复用已经失去可信度的对象
- 把所有 panic 都当必须销毁实例处理,吞吐会被不必要地打掉
- JS 宿主只知道“调用失败了”,却不知道该重试、该丢实例,还是该切断当前会话
这也是 Wasm 运行时可靠性里最现实的一件事:恢复语义必须先被定义出来,后面的隔离和调度才有落点。
绑定层不补恢复语义,宿主层就会拿坏状态继续接活
这类问题最危险的地方,不在业务代码里,而在绑定层看起来“已经帮忙兜住了”。宿主层经常只看见一个抛出来的错误对象,然后把它记成一次普通调用失败。日志是有了,页面也没立刻崩,系统却可能已经把坏状态留在实例内部。
真正需要补的,不只是 try/catch,而是失败后的处置动作。类似下面这种逻辑才算刚刚开始进入可靠性设计:
async function runWithRecovery(instance, input) {
try {
return await instance.exports.handle(input)
} catch (error) {
if (isAbort(error)) {
pool.replace(instance.id)
}
throw error
}
}
这段代码的重点不在语法,而在一个简单判断:当前失败是不是已经把这份实例打成了不可信对象。如果答案是是,恢复动作就不该停在抛错,而要继续往下走到实例淘汰、资源重建、请求切流。
只要这一层没有定义清楚,系统表面上是在处理错误,实际做的却是把一份可能已经损坏的运行时重新放回生产路径。
共享实例会把恢复问题放大成池化策略问题
Wasm 被放进真实产品之后,很少只有“一个实例跑到页面关闭”为止。更常见的是实例池、worker 池,或者前台文档和后台任务共享一组运行时资源。到了这个阶段,panic 和 abort 的恢复成本会直接改写池化策略。
如果实例初始化很贵,系统自然会倾向于尽量复用。可一旦复用成立,故障隔离就必须同步升级:
- 哪些状态只能挂在单次调用里,失败后随调用一起丢掉
- 哪些缓存允许跨调用保留,哪些缓存一旦遇到 abort 必须整份作废
- 实例被替换之后,正在排队的任务怎么迁移,重试会不会把副作用做两遍
这些都不是语言层会自动送出来的答案。它们属于运行时设计。
也正因为这样,Rust/Wasm 的可靠性讨论如果只停在“panic 能不能 catch”,很容易把问题看浅。真正拉开维护成本差距的,是实例池在失败之后还能不能维持清晰的可信边界。
适用边界跟生命周期强相关
这套恢复设计并不是每个 Wasm 项目都要背。
如果模块只是一次性离线工具,或者页面销毁时整个实例一起回收,那么 panic 和 abort 的差别虽然仍然存在,但恢复收益会小得多。页面直接刷新、任务直接重跑,往往已经够用。
一旦系统具备下面这些特征,恢复语义就会迅速从“优化项”变成“基础设施项”:
- 实例长期驻留,不跟单次页面生命周期一起销毁
- 同一实例连续承接多轮调用
- 宿主层需要通过池化来换取启动时间和吞吐
- 失败后还要保护会话状态、缓存状态和排队任务
移动端团队把原生能力搬到 Web,最容易遇到的就是这条边界。原来在 App 进程里默认成立的隔离关系,到了 JS/Wasm 宿主边界之后,经常要自己重新补一遍。
Wasm 让原生代码更容易进入浏览器,但它不会顺手把运行时恢复语义也一起带过去。系统只要开始共享实例、复用状态、长期承接调用,panic 和 abort 就必须被当成两种不同的运行时事件来处理。前者关心当前调用怎么收口,后者关心这份实例还能不能继续活在池子里。这个判断没有先立住,代码移植得越成功,后面的故障越难收。