Rust 以内存安全著称,但在 WebAssembly 的运行环境里,它有一个长期被低估的可靠性问题:一次 panic 或者 abort,可以把整个 Worker 实例彻底毒化,让后续所有请求都跟着失败

这个问题在 Cloudflare 的 Rust Workers 上存在了相当长的时间。今年四月,他们系统性地解决了它,并把相关改动贡献回了 wasm-bindgen 上游项目。

这篇文章完整梳理他们是怎么一步步做到的。

原文链接:https://blog.cloudflare.com/making-rust-workers-reliable/
wasm-bindgen 仓库:https://github.com/wasm-bindgen/wasm-bindgen
workers-rs 仓库:https://github.com/cloudflare/workers-rs


先理解问题的根源

Rust Workers 的运行方式,是把 Rust 代码编译为 WebAssembly(Wasm),然后在 Cloudflare 的 Workers 运行时(基于 V8 引擎)里执行。

在原生 Rust 环境里,panic 分两种行为:

  • panic=unwind:触发栈回溯(unwinding),析构函数(Drop)正常执行,程序可以通过 catch_unwind 捕获并恢复
  • panic=abort:直接终止,没有任何清理,进程退出

Rust 编译到 wasm32-unknown-unknown 目标时,默认是 panic=abort。这意味着一旦 Wasm 内部发生 panic,它会触发一条 unreachable 指令,抛出 WebAssembly.RuntimeError 异常,然后执行路径直接跳出 Wasm 回到 JavaScript。

问题在于:Wasm 实例是有状态的。一次 panic 让 Wasm 的执行中途中断,实例内部的状态可能处于不一致的中间状态。如果这个实例还在继续处理其他并发请求,那些请求读到的就是已经被污染的内存状态,产生难以预测的错误。

更严重的是:这种失效状态有时不会被立刻清理,而是持续影响后续进入的新请求,直到实例被回收。一个请求的失败,可以扩散成一片失败。


第一阶段:用 JavaScript 包装打补丁(workers-rs 0.6)

面对这个问题,Cloudflare 最初的方案是在 JavaScript 和 Rust 之间的调用边界做拦截。

具体做法是:

  • 自定义 panic handler:在 Rust 侧植入一个全局 panic 跟踪器,记录失败状态
  • Proxy 包装层:在 JavaScript 侧用 Proxy 对所有 Rust-JS 入口点做统一拦截,一旦检测到 Wasm 处于失效状态,触发全量重新初始化
  • 模块重初始化:失败后重新加载 Wasm 模块,让实例回到干净状态

这套方案解决了"Worker 被彻底搞死"的问题,并从 workers-rs 0.6 版本开始默认对所有用户启用。

但它有一个明显的局限:重新初始化意味着丢弃整个 Wasm 实例的内存状态

对于无状态的请求处理器来说,这没什么问题,反正每次请求之间也没有共享状态。但对于 Durable Objects——Cloudflare 的强一致性有状态存储原语——来说,实例内存里维护着跨请求共享的业务状态。一个请求触发了 panic,重新初始化会把其他并发请求正在使用的状态一起清掉,造成数据丢失。

治标不治本。根本的问题需要在 panic 机制本身上解决。


第二阶段:让 Wasm 真正支持栈回溯(panic=unwind)

原生 Rust 里,panic=unwind 允许 panic 像异常一样向上传播,沿途执行 Drop,并允许调用方用 catch_unwind 捕获。这样可以在不丢失整体状态的前提下,隔离单次失败的影响范围。

Wasm 里历史上没有等价的机制,直到 WebAssembly Exception Handling 提案在 2023 年获得主流引擎的广泛支持。这个提案在 Wasm 字节码层面引入了 try/catch/throw 指令,让 Wasm 模块可以像宿主语言一样处理异常。

基于这个提案,Cloudflare 为 wasm-bindgen 实现了完整的 panic=unwind 支持。

编译层的变化

RUSTFLAGS='-Cpanic=unwind' cargo build -Zbuild-std 编译时,含有 Drop 的代码会生成对应的 Wasm 异常处理指令。例如以下 Rust 代码:

fn some_func() {
    let a = HasDropA;
    let b = HasDropB;
    imported_func(); // 可能 panic
}

编译后的 Wasm 字节码大致为:

try
  call <imported_func>
catch_all
  call <drop_b>
  call <drop_a>
  rethrow
end
call <drop_b>
call <drop_a>

即使 imported_func 内部 panic,析构函数也能按正确顺序执行,Wasm 实例的内存状态不会停留在中间态。

wasm-bindgen 工具链的改造

要让整个工具链支持这套机制,需要改动多个环节:

  • Walrus(Wasm 解析器):原来不认识 try/catch 指令,需要新增支持
  • 描述符解释器:需要能正确解析包含异常处理块的代码
  • Rust-JS 边界:wasm-bindgen 生成的导出函数需要在最外层捕获 panic,以 PanicError 的形式抛出给 JavaScript;对于 async 导出,panic 会以 PanicError 拒绝(reject)对应的 Promise
  • extern "C-unwind":Rust 的 extern "C" 会在 unwind 穿越时触发 abort,导出函数需要改为 extern "C-unwind" 才能允许 panic 向上传播

闭包的处理还需要额外的细心。很多闭包会捕获引用,而引用在 unwind 之后可能仍然存活,这违反了 unwind 安全性(UnwindSafe)的要求。为此新增了 MaybeUnwindSafe trait,并提供了 Closure::new_aborting 变体——在 unwind 不安全的场景下主动触发 abort,而不是让编译器强迫用户加 AssertUnwindSafe

最终效果:

  • panic 被 wasm-bindgen 捕获,作为 JavaScript 异常抛出
  • Rust 析构函数正确执行
  • Wasm 实例继续有效,可以处理后续请求
  • Durable Objects 的内存状态不再因为单个请求的 panic 而丢失

第三阶段:abort 恢复——兜住最坏情况

即使有了 panic=unwind,abort 仍然存在。内存耗尽(OOM)是最常见的触发原因,abort 无法 unwind,没有状态恢复的可能。

abort 恢复的目标不是恢复状态,而是:确保 abort 之后,失败状态不会污染后续请求

区分可恢复错误与不可恢复错误

引入 panic=unwind 之后,出现了一个新问题:从 Wasm 抛出的错误,可能来自 extern "C-unwind" 的 unwind,也可能来自真正的 abort,从外部看不出区别。

Cloudflare 的解决思路是给 unwind 标记上特定的 Exception Tag(WebAssembly 异常处理提案提供的机制),让 abort 产生的错误没有这个标记,从而可以在 JavaScript 侧精确区分两种来源。

set_on_abort:平台级的恢复钩子

基于这套区分机制,wasm-bindgen 新增了 set_on_abort 钩子,允许在初始化时注册一个自定义的 abort 处理函数。Worker 实例可以在 abort 发生时执行必要的清理,然后将实例标记为不可再用,确保后续请求不会进入一个状态未知的实例。

同时还加入了 abort 重入保护:防止在 abort 处理过程中因为深度交错的 Wasm-JS 调用栈导致 abort 处理逻辑本身被重复触发。


延伸:wasm-bindgen 库的自动重初始化

这套机制不只对 Rust Workers 有用。在 JavaScript Workers 里,开发者有时会直接依赖用 Rust 编写、编译为 Wasm 的 npm 包(比如 import { func } from 'wasm-dep')。如果这类库内部发生 abort,调用方的 JS 代码会收到一个错误,但那个 Wasm 实例可能已经处于无效状态,后续调用会持续失败。

为此,wasm-bindgen 新增了实验性的 --reset-state-function 功能。它暴露一个函数,允许 Rust 应用声明"我需要重置到初始状态",而不需要调用方重新 import 或重建绑定对象。旧实例上的类实例会失效(handles 变成孤立状态),但新的类可以被构建出来,整个 JS 应用得以继续运行。

区别在于:错误发生后应用变得"出错了",但不会变成"彻底坏掉了"。


推动整个生态跟上:Node.js 的贡献

WebAssembly Exception Handling 提案经历过一次规范晚期修改,分裂成两个版本:

  • legacy 版本:已被广泛支持,但已被标记为废弃
  • 现代版本(with exnref):才是正式规范,各引擎支持时间如下:
运行时支持版本发布时间
Chrome1382025.06.28
Firefox1312024.10.01
Safari18.42025.03.31
Node.js25.0.02025.10.15
workerd(Workers 运行时)v1.20250620.02025.06.19

问题在于 Node.js 24 LTS 的发布节奏:如果不做干预,整个生态在 2028 年 4 月之前都无法切换到现代版本,因为 LTS 用户会一直停留在 Node.js 24 上。

Cloudflare 发现这个问题后,主动将现代 Exception Handling 反向移植到了 Node.js 24Node.js 22 的发行版本,消除了这个长达三年的阻塞。


现在可以怎么用

workers-rs 0.8.0 开始,构建命令支持 --panic-unwind 标志:

# 在构建时加上这个标志
workers-rs build --panic-unwind

启用后:

  • panic 完全可恢复,不会丢失 Durable Objects 状态
  • abort 会触发新的恢复钩子,失败状态不再扩散
  • 下一个版本计划将 panic=unwind 作为默认行为

仍然使用 panic=abort 的用户,依然享有 0.6.0 引入的自定义恢复包装器,但无法做到无损恢复。


这件事背后的工程文化

这篇博客让人印象深刻的,不只是技术本身,而是解决问题的路径:发现问题,修平台,顺手把上游生态一起推进

Walrus 加了对新 Wasm 指令的支持,wasm-bindgen 有了 panic/abort 恢复能力,Node.js 24/22 的 LTS 用户不用等到 2028 年才能用到现代异常处理——这些改动都不是 Cloudflare 的"私有修复",而是贡献回了对应的开源项目。

最终受益的不只是 Cloudflare Workers 的用户,而是整个 Rust + WebAssembly 的开发者社区。这是基础设施公司参与开源生态的一种比较理想的姿态。


标签: none

添加新评论