WASI 0.3 深度拆解:当 WebAssembly 决定把 async 焊进 ABI——wasi:io 整包删除、三明治问题终结,一次重写组件间异步语义的底层手术
如果你在 2024、2025 年试过用 WASI 0.2 写一个 HTTP 中间件组件,大概率经历过这样一个下午:代码逻辑看起来完全正确,pollable 也订阅了,poll() 也调了,但请求就是会莫名其妙地卡住,或者 CPU 空转到 100%。你翻遍了文档,最后在某个 issue 里看到一句轻描淡写的话:
"pollable is scoped to a single component instance."
那一刻你才意识到:WASI 0.2 能表达异步,但不能跨组件边界组合异步。
2026 年 6 月 11 日,WASI 0.3.0 正式发布。它做的事情很激进:把整个 wasi:io 包删掉,把异步能力从"标准库里的一个包"下沉到组件模型的 Canonical ABI里。这不是加了几个 API,这是把地基刨开重新浇了一遍。
这篇文章会把这次手术拆到底:为什么必须这么改、改完之后每个接口长什么样、代码怎么写、迁移会踩哪些坑、以及——什么时候你不该着急迁。
一、先把三代 WASI 的账算清楚
很多人对 WASI 的版本号是懵的,因为它同时有两套叫法。先把表列清楚:
| 叫法 A | 叫法 B | 状态 | 二进制形态 | 核心特征 |
|---|---|---|---|---|
| WASI 0.1 | Preview 1 / P1 | Legacy | core module | POSIX 风格的扁平函数导入,wasi_snapshot_preview1 |
| WASI 0.2 | Preview 2 / P2 | Stable(2024-01-25) | component | WIT 接口、组件模型、跨语言可组合 |
| WASI 0.3 | Preview 3 / P3 | Stable(2026-06-11) | component | 原生异步:async func / stream<T> / future<T> |
有几个事实值得单独拎出来:
第一,0.1 到今天依然是产量最大的。 因为它简单——就是一堆 fd_read、fd_write、path_open 这样的扁平导入,任何一个能跑 core module 的运行时都能支持。生产环境里跑的 Wasm,很大一部分还是 0.1。
第二,0.2 是范式转变,不是版本升级。 从 core module 变成 component,引入了 WIT(Wasm Interface Type)、world、resource、record、variant 这一整套类型系统。它的杀手锏是跨语言组合:Rust 编译出来的组件能直接和 Go 编译出来的组件对接,不需要 HTTP、不需要 gRPC、不需要 protobuf 序列化。
第三,0.3 解决的是 0.2 留下的一个结构性缺陷。 而且这个缺陷严重到——只要你的架构里出现"组件调组件",它就一定会咬你。
我们来把这个缺陷讲透。
二、三明治问题:WASI 0.2 异步模型的结构性坍塌
2.1 WASI 0.2 是怎么做异步的
在 0.2 里,异步靠 wasi:io 包,核心是三个东西:
// wasi:io/poll@0.2.x
interface poll {
resource pollable {
ready: func() -> bool;
block: func();
}
poll: func(in: list<borrow<pollable>>) -> list<u32>;
}
// wasi:io/streams@0.2.x
interface streams {
resource input-stream {
read: func(len: u64) -> result<list<u8>, stream-error>;
subscribe: func() -> pollable; // ← 关键
}
resource output-stream {
check-write: func() -> result<u64, stream-error>;
write: func(contents: list<u8>) -> result<_, stream-error>;
subscribe: func() -> pollable; // ← 关键
}
}
用起来的心智模型和 epoll 几乎一模一样:
- 从某个资源上
subscribe()拿到一个pollable - 把一堆
pollable攒起来丢给poll() poll()阻塞直到至少一个 ready,返回 ready 的下标- 对 ready 的那些资源做实际 I/O
- 回到 1
单个组件直接和 host 打交道时,这套东西工作得很好。问题出在组件被组合起来的时候。
2.2 断裂点:pollable 是实例作用域的资源
考虑一个最普通不过的场景——一个 HTTP 中间件:
组件 A(业务逻辑) → 组件 B(鉴权/日志中间件) → Host(真实网络)
B 需要把 A 的请求转发给 host,再把 host 的响应转回给 A。现在问题来了:
- Host 给 B 一个
pollable,表示"网络数据到了" - B 想把这个 ready 信号转发给 A
- 但
pollable是 resource,resource handle 的作用域是单个组件实例
B 手里的 pollable handle 是 B 实例的 handle table 里的一个索引。这个索引对 A 毫无意义,也不能跨实例边界传递语义。B 唯一能做的是:
// 组件 B 内部,伪代码
loop {
// B 自己 poll host 的 pollable
let ready = poll(&[host_pollable.borrow()]);
if !ready.is_empty() {
// 数据到了,B 得自己造一个新的 pollable 通知 A
// 但 B 没有办法"造" pollable —— 它只能从资源上 subscribe
// 于是 B 只能:主动轮询,或者用一个假的 ready pollable 让 A 空转
}
}
结局是三选一,没有一个是好的:
- B 起一个忙轮询循环——CPU 白烧,而且 B 本身是被动调用的,它根本没有"自己的执行线程"来跑这个循环
- B 给 A 返回一个"永远 ready"的 pollable——A 每次 poll 都立刻返回,然后读到
would-block,退化成忙等 - B 直接 block——整条链上的其他并发任务全被卡死,彻底丧失并发性
这就是社区里说的 sandwich problem(三明治问题):面包(A 和 Host)都能异步,夹在中间的那片(B)把唤醒链吃掉了。
2.3 为什么这不是"实现没做好",而是"设计上做不到"
有人会说:那让 host 把 pollable 直接透传给 A 不就行了?
不行。组件模型的核心承诺之一是能力隔离:一个组件实例只能看到显式授予它的能力。pollable 作为 resource,它的 handle 归属于持有它的那个实例。如果允许 handle 裸奔穿越实例边界,整个 capability-based 安全模型就漏了。
另一条路是:让 wasi:io 提供一个"合并/转发 pollable"的原语。但这本质上是在用户态库里重新发明一个调度器,而这个调度器还必须能跨实例工作——它需要知道所有实例的等待关系,需要能唤醒任意实例。这已经是 runtime 的活了,硬塞进一个 WIT 包里只会做出一个又慢又漏的半成品。
结论:异步必须由 runtime 拥有,不能由库拥有。
这个道理其实不新鲜。对比一下:
- Go 的 goroutine 调度器在 runtime 里,不在
sync包里 - Rust 的
async是语言级语法 + 编译器变换,tokio只是 executor - 操作系统的进程调度在内核里,不在 libc 里
WASI 0.2 试图把调度放在库层(wasi:io),WASI 0.3 把它拿回了它该在的地方(Canonical ABI)。
三、WASI 0.3 的手术方案:把 async 下沉进 Canonical ABI
3.1 Canonical ABI 是什么
先补个背景,因为这是理解整个 0.3 的关键。
组件模型里有两层:
- Core Wasm 层:只有 i32/i64/f32/f64 和线性内存,这是 Wasm 规范本身
- Component 层:有
string、list<T>、record、variant、resource这些高级类型
Canonical ABI 就是这两层之间的翻译规则:一个 string 怎么在线性内存里布局、list<record> 怎么传、resource handle 怎么在 handle table 里分配和回收——全在这里定义。canon lift 把 core 值提升成组件值,canon lower 反过来。
WASI 0.3 做的事情是:在 Canonical ABI 里新增了异步的内建指令。也就是说,"挂起当前任务"、"唤醒等待方"、"跨实例传播 readiness"这些操作,现在是 ABI 级别的原语,由运行时直接实现。
这一下就把三明治问题从根上解决了:B 不再需要"转发唤醒",因为唤醒链根本不经过 B 的用户代码——它走的是运行时的调度器。
3.2 三个新原语
async func
WIT 里现在可以直接声明异步函数:
// WASI 0.3 的 HTTP handler
handle: async func(request: request) -> result<response, error-code>;
绑定生成器会把它翻译成各语言的原生异步构造:
| 语言 | 生成的形态 |
|---|---|
| Rust | async fn |
| JavaScript | 返回 Promise 的函数 |
| Python | coroutine(async def) |
| Go | 阻塞式 API(由 goroutine 承载) |
| C# | Task<T> |
注意这里的关键差别:**在 0.2 里,"异步"是你在函数返回后自己拿着 pollable 去等;在 0.3 里,"异步"是函数本身的类型属性。**运行时看到 async func,就知道这个调用可以挂起、可以让出、可以被其他任务插队。
stream<T>
类型化的异步数据通道。它和 0.2 的 input-stream/output-stream 有本质区别:
// 0.2:resource,只能借用,不能跨边界传值
resource input-stream { ... }
// 0.3:value type,可以作为参数/返回值自由传递
stream<u8>
stream<directory-entry>
stream<tcp-socket>
stream<T> 是值不是资源。它可以被打包进 record、放进 variant、作为参数传给另一个组件。运行时知道这个 stream 的两端分别在谁手里,背压和唤醒由运行时管。
而且它是类型化的。0.2 的 stream 只有字节流,你想传一串目录项,得自己序列化。0.3 里直接 stream<directory-entry>——组件模型帮你搞定编解码。
future<T>
单值异步完成信号。凡是 0.2 里用 pollable 表达"这件事做完了没"的地方,0.3 都换成了 future<T>:
future<result<_, error-code>>
同样是值,同样可以跨边界传。
3.3 一张替换表,把 0.2 的肌肉记忆全洗掉
这张表建议直接贴在显示器边上:
WASI 0.2(wasi:io) | WASI 0.3(Component Model 原生) |
|---|---|
resource pollable | future<T> |
resource input-stream | stream<u8> |
resource output-stream | stream<u8>(写方向) |
poll(list<pollable>) | 对 future 做 await |
资源上的 subscribe() | 调用直接返回一个 future |
start-foo / finish-foo 拆分 | foo: async func(...) |
wasi:io 整个包被移除了。没有 0.3 版本的 wasi:io。 这是一次干净的删除,不是废弃标记。
四、两个必须刻进肌肉的模式
WASI 0.3 的接口设计高度收敛,翻来覆去就两个模式。吃透这两个,读任何 0.3 的 WIT 都不会懵。
4.1 stream-plus-future 模式
读方向统一长这样:返回一个 stream<T> 和一个 future<result> 的元组。
read-via-stream: func() -> tuple<stream<u8>, future<result<_, error-code>>>;
语义是:
stream<u8>增量地吐数据- 流关闭后,
future解析出这次操作到底是正常结束还是中途出错
为什么要拆成两个?因为流式读取有一个经典难题:你怎么区分"数据读完了"和"读到一半炸了"?
在 POSIX 里,read() 返回 0 表示 EOF,返回 -1 表示错误——错误信息和数据通道是耦合的。在流式 API 里这很别扭,因为流的类型是 stream<u8>,你没地方塞错误码。
0.3 的方案是把数据通道和结果通道分开:流只管吐数据,future 只管报结果。这样类型干净,语义也清晰。
这个模式出现在:stdin、文件系统读、TCP 接收、目录遍历。全都是这个形状。
4.2 写方向翻转(direction flip)——最容易翻车的地方
这是我认为整个 0.3 里最反直觉的一处改动,也是迁移时踩坑最多的地方。
WASI 0.2 的写: 你先拿到一个 output-stream,然后往里写。
get-stdout: func() -> output-stream;
心智模型是"拿到一个水管口,我往里灌水"。
WASI 0.3 的写: 你把一个 stream<u8> 传进去,拿回一个 future<result>。
write-via-stream: func(data: stream<u8>) -> future<result<_, error-code>>;
心智模型变成"我提供一个水源,你去抽"。
方向反了。 控制权从调用方转移到了被调用方。
为什么这么设计?因为这样才能让"零拷贝转发"成为可能。想象一个代理组件:从 socket 读,往文件写。
在 0.2 里你必须:
socket.input-stream → 读进组件的线性内存 → 写进 file.output-stream
数据必须穿过组件的线性内存,至少两次拷贝。
在 0.3 里你可以:
let (data_stream, read_done) = socket.receive();
let write_done = file.write_via_stream(data_stream); // 直接把流交出去
组件把 stream 的所有权直接转交,数据根本不用进这个组件的内存。运行时可以在 host 侧做 splice/sendfile 级别的优化。
这就是为什么方向要翻——不翻的话,中间组件永远是数据的必经之路,零拷贝无从谈起。
stdout、stderr、文件系统写、TCP 发送,全部适用这个翻转。
五、逐个接口拆解:到底改了什么
5.1 wasi:http——重构幅度最大的一个
资源大坍缩。 0.2 的 HTTP 接口有一堆资源,光是描述一次请求-响应就要动用:
incoming-request
outgoing-request
incoming-response
outgoing-response
incoming-body
outgoing-body
future-trailers
future-incoming-response
response-outparam
九个资源。写过 0.2 HTTP 组件的人都知道这有多痛——你得记住 body 什么时候能取、取了之后原来的 handle 还能不能用、response-outparam 什么时候必须 set 否则运行时会挂。
0.3 全砍了,只剩两个:
request
response
body 就是 stream<u8>,trailers 就是 future<trailers>。完事。
handler 变成 async func:
// 服务端
handle: async func(request: request) -> result<response, error-code>;
// 客户端
send: async func(request: request) -> result<response, error-code>;
注意这两个签名一模一样。这不是巧合——这正是 middleware world 能成立的前提。
world 的变化:
| 0.2 | 0.3 | 说明 |
|---|---|---|
wasi:http/proxy | wasi:http/service | 服务端 world 改名并重构 |
| —— | wasi:http/middleware | 全新,既 import 又 export handler |
middleware world 是这次改动的点睛之笔。它同时导入和导出 handler 接口:
world middleware {
import handler; // 我能调下游
export handler; // 我能被上游调
}
这意味着中间件组件成了一等公民。你可以把 N 个中间件串起来:
[auth] → [ratelimit] → [logging] → [业务组件]
每一段都是独立编译的组件,可以来自不同语言,可以独立替换。而且——因为异步在 ABI 层,这条链上的唤醒传播是运行时管的,三明治问题不复存在。
新增错误变体: header-error 增加了 size-exceeded,用于请求头超过运行时配置上限的情况。0.2 时代这种情况的表现是各家运行时各行其是(有的返回通用错误,有的直接断连),现在标准化了。
5.2 wasi:sockets——7 个接口砍成 2 个
接口合并。 0.2 有这些接口:
network
instance-network
tcp
tcp-create-socket
udp
udp-create-socket
ip-name-lookup
0.3 变成:
types // 里面包含 tcp-socket 和 udp-socket 两个 resource
ip-name-lookup // DNS 单独留着
network resource 被移除。 这是个语义层面的改动,值得单独说。
0.2 里,你要先拿到一个 network handle,才能创建 socket——network 就是"网络访问能力"的具象化。0.3 把这个能力提升到了 world 层级:你的组件 world 里有没有 sockets 的 import,就决定了它能不能上网。
这更符合 capability-based security 的原教旨:能力应该在链接时静态确定,而不是运行时传来传去的一个 handle。
start/finish 全部合并成 async func:
// 0.2
start-connect: func(network: borrow<network>, remote: ip-socket-address) -> result<_, error-code>;
finish-connect: func() -> result<tuple<input-stream, output-stream>, error-code>;
// 0.3
connect: async func(remote-address: ip-socket-address) -> result<_, error-code>;
这个 start-*/finish-* 的拆分模式,本来就是"没有 async 只能手动状态机"的产物。现在有了 async func,它自然消失了。
listen 返回一个 socket 流:
listen: func() -> result<stream<tcp-socket>, error-code>;
这个设计我个人觉得非常漂亮。传统 accept 循环是这样的:
while (1) {
int fd = accept(listen_fd, ...);
if (fd < 0) { /* 处理 EAGAIN、EMFILE、EINTR... */ }
handle(fd);
}
而在 0.3 里,"接受连接"就是"从一个 stream<tcp-socket> 里取值"。背压、关闭、错误传播全都复用流的语义,不需要再单独设计一套。
新增错误变体: error-code 增加 connection-broken,对应 POSIX 的 EPIPE。这解决了 0.2 的一个真实痛点——你没法区分"对端正常关闭后我还在写"和"连接被 reset/abort 了",而这两种情况在重试策略上是完全不同的(前者不该重试,后者可能该重试)。
5.3 wasi:cli
stdio 走 stream-plus-future:
get-stdin: func() -> tuple<stream<u8>, future<result<_, error-code>>>;
write-via-stream: func(data: stream<u8>) -> future<result<_, error-code>>; // stdout/stderr
run 变成 async:
run: async func() -> result;
这个改动的意义比看上去大。0.2 里 run() 是同步的,意味着一个 CLI 组件在等 I/O 的时候,整个实例是阻塞的。0.3 之后,一个 CLI 组件内部可以有真正的并发任务。
新增 types 接口,定义了跨 CLI 各接口共用的 error-code enum。0.2 时代每个接口自己定义错误类型,跨接口传播错误得手动映射,很烦。
exit 接口扩容:
exit: func(status: result);
exit-with-code: func(status-code: u8);
exit-with-code 是 2026 年 6 月从 Phase 2 提升进稳定接口的。为什么需要它?因为 0.2 的 exit(result) 只能表达"成功/失败"两态,而 Unix 世界里退出码是有语义的——grep 返回 1 表示没匹配到,diff 返回 1 表示有差异,2 才是真出错。只能返回 0/1 的 CLI 组件根本没法做 Unix 工具的替代品。
5.4 wasi:filesystem
几乎所有 descriptor 方法都变成了 async func。 这个改动很实在——文件 I/O 本来就是最典型的阻塞点。
目录遍历返回流:
read-directory: func() -> tuple<stream<directory-entry>, future<result<_, error-code>>>;
注意这是 stream<directory-entry> 而不是 stream<u8>——类型化流在这里的价值就体现出来了。0.2 里遍历目录要拿一个 directory-entry-stream resource,然后循环调 read-directory-entry(),每次返回 option<directory-entry>,用 none 表示结束。现在流的语义天然覆盖了这些。
新增兜底错误变体:
other(option<string>)
这是个务实的妥协。文件系统的错误空间是发散的——不同 OS、不同 fs、不同挂载选项能产生的错误种类太多了,枚举不完。0.2 的做法是硬枚举,结果运行时遇到没枚举的错误只能瞎映射成一个最接近的变体,调试时极具误导性。现在有了 other(option<string>),至少能把原始错误信息带出来。
5.5 wasi:clocks——一次改名带来的连锁反应
接口和类型改名:
| 0.2 | 0.3 |
|---|---|
wall-clock | system-clock |
datetime | instant |
改名的理由是向 POSIX(CLOCK_REALTIME)和 Rust std::time 靠拢。wall-clock 是个口语化说法,system-clock 更准确;datetime 容易让人以为它带时区和日历语义,instant 明确它就是一个时间点。
instant 的秒字段从 u64 改成 s64。 这是个真正的功能修复:u64 意味着无法表示 1970 年之前的时间戳。文件系统里 mtime 早于 epoch 的文件是真实存在的(尤其是从老系统迁移过来的数据),0.2 下这些时间戳只能被截断或者报错。
subscribe 系列换成 async:
// 0.2
subscribe-instant: func(when: instant) -> pollable;
subscribe-duration: func(when: duration) -> pollable;
// 0.3
wait-until: async func(when: mark);
wait-for: async func(how-long: duration);
wait-for 现在就是最朴素的 sleep,不需要 pollable 中转。
5.6 wasi:random——一个字改名,语义完全变了
看起来最小的改动,杀伤力可能最大:
// 0.2
get-random-bytes: func(len: u64) -> list<u8>;
// 0.3
get-random-bytes: func(max-len: u64) -> list<u8>;
len → max-len。
这不是改个名字好看,这是语义变更:实现可以返回比请求更少的字节。
也就是说,这段 0.2 代码在 0.3 下是有 bug 的:
// ❌ 0.3 下不安全
let key = random::get_random_bytes(32);
assert_eq!(key.len(), 32); // 可能失败
aes_encrypt(&key, data); // 可能拿到 17 字节的"密钥"
正确写法必须循环:
// ✅ 0.3 正确写法
fn fill_random(n: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(n);
while out.len() < n {
let remaining = (n - out.len()) as u64;
let chunk = random::get_random_bytes(remaining);
if chunk.is_empty() {
// 实现返回 0 字节,避免死循环
panic!("random source exhausted");
}
out.extend_from_slice(&chunk);
}
out
}
这个坑的恶劣之处在于:它不会立刻炸。 大多数运行时在大多数时候会老老实实返回你要的长度,只有在熵池紧张、或者请求特别大的时候才会短返回。你的测试全绿,上线跑三个月,某天生成了一个 12 字节的"256 位密钥"。
如果你只从这篇文章记住一件事,就记这个。
六、代码实战
6.1 环境准备
# Rust 侧
rustup target add wasm32-wasip2 # 目标三元组
cargo install cargo-component
cargo install wasm-tools
# 运行时:需要 Wasmtime 43+
curl https://wasmtime.dev/install.sh -sSf | bash
wasmtime --version # 确认 >= 43
# JS 侧
npm install -g @bytecodealliance/jco
版本对齐是重灾区,后面单开一节讲。
6.2 一个 WASI 0.3 的 HTTP service 组件(Rust)
先看 WIT:
// wit/world.wit
package example:hello@0.1.0;
world hello-service {
include wasi:http/service@0.3.0;
}
Rust 实现:
use bindings::exports::wasi::http::handler::Guest;
use bindings::wasi::http::types::{Request, Response, ErrorCode, Headers};
struct Component;
impl Guest for Component {
// 注意:这是 async fn,绑定生成器直接给了你原生 async
async fn handle(request: Request) -> Result<Response, ErrorCode> {
let path = request.path_with_query().unwrap_or_default();
// body 就是 stream<u8>,不再需要 incoming-body / outgoing-body 那一坨
let body_stream = request.consume_body();
let mut collected = Vec::new();
// 直接 await,运行时负责挂起和唤醒
while let Some(chunk) = body_stream.next().await {
collected.extend_from_slice(&chunk?);
}
let headers = Headers::new();
headers.append("content-type", b"text/plain")?;
let resp = Response::new(headers);
resp.set_status_code(200)?;
resp.write_body(format!(
"path={} body_len={}\n", path, collected.len()
).into_bytes()).await?;
Ok(resp)
}
}
bindings::export!(Component with_types_in bindings);
对比一下 0.2 版本的等价代码,你会发现少了:
- 手动创建
response-outparam并在正确时机set - 从
incoming-request里取incoming-body,再从 body 取input-stream subscribe()拿 pollable、poll()等待、检查 ready、read()、处理would-block- 手动关闭
outgoing-body并处理 trailers
代码量大概能砍掉 60%,而且语义正确性不再依赖你记住那些隐式的生命周期规则。
构建和运行:
cargo component build --release --target wasm32-wasip2
# 检查一下生成的组件导出了什么
wasm-tools component wit target/wasm32-wasip2/release/hello.wasm
# 起服务
wasmtime serve -Sp3 -W component-model-async=y \
target/wasm32-wasip2/release/hello.wasm
curl -X POST localhost:8080/test -d "hello wasi 0.3"
# path=/test body_len=14
那两个 flag 现在还是必须的:-Sp3 启用 WASI 0.3 接口,-W component-model-async=y 打开组件模型异步。Wasmtime 46 之后 async 会默认开启。
6.3 中间件组件:0.3 真正的杀手锏
这个 demo 最能体现 0.3 的价值——在 0.2 里这玩意儿根本没法优雅实现。
// wit/world.wit
package example:auth-mw@0.1.0;
world auth-middleware {
include wasi:http/middleware@0.3.0;
}
use bindings::exports::wasi::http::handler::Guest as Export;
use bindings::wasi::http::handler as downstream; // ← 导入的下游 handler
use bindings::wasi::http::types::{Request, Response, ErrorCode, Headers};
struct AuthMiddleware;
impl Export for AuthMiddleware {
async fn handle(request: Request) -> Result<Response, ErrorCode> {
// 1. 前置:鉴权
let token = request.headers()
.get("authorization")
.into_iter()
.next()
.and_then(|v| String::from_utf8(v).ok());
match token.as_deref() {
Some(t) if t.starts_with("Bearer ") && verify(&t[7..]) => {}
_ => {
let headers = Headers::new();
let resp = Response::new(headers);
resp.set_status_code(401)?;
resp.write_body(b"unauthorized\n".to_vec()).await?;
return Ok(resp); // 短路,下游根本不会被调用
}
}
// 2. 透传给下游 —— 这里是关键
// request 里的 body 是 stream<u8>,直接转交所有权
// 数据不需要经过本组件的线性内存
let mut resp = downstream::handle(request).await?;
// 3. 后置:加个响应头
resp.headers().append("x-auth-by", b"auth-mw")?;
Ok(resp)
}
}
fn verify(token: &str) -> bool {
// 真实场景这里做 JWT 验签
!token.is_empty() && token.len() > 8
}
bindings::export!(AuthMiddleware with_types_in bindings);
把它和业务组件组合起来:
# 用 wac 做组件组合
wac plug auth_mw.wasm --plug business.wasm -o app.wasm
wasmtime serve -Sp3 -W component-model-async=y app.wasm
注意 downstream::handle(request).await 这一行。 在 0.2 的世界里,这一行背后是:中间件必须自己起一个 poll 循环,把下游的 pollable 和上游的 pollable 揉在一起管理,还要处理 body stream 的双向转发和背压——大概三百行不好调的代码,而且再套一层中间件就会退化。
在 0.3 里,它就是一行 await。唤醒链由运行时维护,套十层也一样。
6.4 CLI 组件:流式处理 stdin
use bindings::exports::wasi::cli::run::Guest;
use bindings::wasi::cli::{stdin, stdout};
struct Cli;
impl Guest for Cli {
async fn run() -> Result<(), ()> {
// stream-plus-future 模式
let (input, read_done) = stdin::get_stdin();
// 一个把输入转大写的流式管道
let (tx, rx) = wit_stream::new::<u8>();
// 把输出流交给 stdout(写方向翻转)
let write_done = stdout::write_via_stream(rx);
// 边读边写,不需要把整个输入缓存到内存
while let Some(chunk) = input.next().await {
let chunk = chunk.map_err(|_| ())?;
let upper: Vec<u8> = chunk.iter()
.map(|b| b.to_ascii_uppercase())
.collect();
tx.write(upper).await.map_err(|_| ())?;
}
drop(tx); // 关闭流,让 write_done 能 resolve
// 两个 future 都要等
read_done.await.map_err(|_| ())?;
write_done.await.map_err(|_| ())?;
Ok(())
}
}
bindings::export!(Cli with_types_in bindings);
cargo component build --release --target wasm32-wasip2
echo "hello wasi" | wasmtime run -Sp3 -W component-model-async=y upper.wasm
# HELLO WASI
# 试试大文件,观察内存占用是恒定的
/usr/bin/time -l sh -c 'cat 2gb.txt | wasmtime run -Sp3 -W component-model-async=y upper.wasm > /dev/null'
这段代码的价值在于:内存占用和输入大小无关。在 0.2 里要做到这一点,你得手写 check-write / write / subscribe / poll 的背压循环,而且很容易写出死锁(经典错误:先等读 ready 再等写 ready,两边互相等)。
6.5 JavaScript 侧(jco)
jco 会把 async func 映射成 Promise:
// component.js
export const handler = {
async handle(request) {
const url = request.pathWithQuery() ?? '/';
// body 是 async iterable
const chunks = [];
for await (const chunk of request.consumeBody()) {
chunks.push(chunk);
}
const body = Buffer.concat(chunks);
const headers = new Headers();
headers.append('content-type', 'application/json');
const resp = new Response(headers);
resp.setStatusCode(200);
await resp.writeBody(
Buffer.from(JSON.stringify({ url, size: body.length }))
);
return resp;
}
};
jco componentize component.js \
--wit wit/ \
--world-name hello-service \
-o component.wasm
wasmtime serve -Sp3 -W component-model-async=y component.wasm
值得强调的是:这个 JS 组件和上面那个 Rust 中间件可以直接组合。 不需要 HTTP、不需要 JSON 序列化、不需要进程间通信。wac plug rust_auth.wasm --plug js_business.wasm -o app.wasm,就是一个二进制。
这才是组件模型真正想卖的东西,而 0.3 是让它在异步场景下终于成立的那块拼图。
七、性能与工程分析:这次改动到底赚了什么
7.1 消灭 poll 循环的三笔账
第一笔:O(n) 扫描。 poll(list<pollable>) 的语义要求运行时遍历整个列表检查 readiness。等待的资源越多,每次唤醒的成本越高。这是 select/poll 系统调用的老问题,Linux 用 epoll 解决了,而 WASI 0.2 又把它重新引入了一遍。0.3 的 future 是由完成方主动通知的,成本和等待数量无关。
第二笔:伪唤醒。 poll() 返回后你得逐个检查哪些真的 ready,然后对每个 ready 的做实际 I/O,还得处理"poll 说 ready 但 read 返回 would-block"(惊群、边缘触发的经典陷阱)。这些代码在 0.3 里全部消失。
第三笔,也是最大的一笔:跨组件的数据拷贝。 前面讲写方向翻转时提过:0.2 下中间组件必须是数据的必经之路。一条三层的中间件链,数据要穿过三个线性内存,就是六次拷贝(进+出)。0.3 下 stream 所有权可以直接转交,理想情况下是零次。
对于一个 HTTP 网关类的负载,这个差异可能是数量级的。
7.2 背压是怎么表达的
这是我一开始最担心的点:把 stream 做成值类型之后,背压还管不管用?
答案是管用,而且更清晰了。stream<T> 的写端在下游没有消费能力时,write() 会挂起(await 不返回)。这是运行时层面的背压,不是靠用户代码 check-write() 自觉遵守。
对比一下:
// 0.2:背压靠自觉
let n = out.check_write()?; // 问一下能写多少
if n == 0 {
out.subscribe().block(); // 忘了写这行 → 数据丢失或 panic
}
out.write(&data[..n as usize])?; // 忘了截断 → 报错
// 0.3:背压是类型保证
tx.write(data).await?; // 写不下就挂起,写不了就报错,没有第三种情况
0.2 的 API 让"正确使用"和"错误使用"看起来一样自然,这是很糟糕的 API 设计。0.3 里错误用法根本写不出来。
7.3 还没做完的部分
WASI 0.3.0 只是起点,roadmap 上明确列了后续 0.3.x 点版本(每两个月一次,release train 模式)会做的事:
- 取消(cancellation)与语言习惯的自动集成——目前 0.3.0 的取消语义是有的,但和 Rust 的
Drop、JS 的AbortController之类还没打通。这是现阶段最明显的短板。 tuple、future等类型的特化——降低这些聚合类型的 ABI 开销。- Stream 优化:Canonical ABI 内建的 forwarding/splicing、skipping/writing-zeroes、stream data segment、lulls——这批做完,前面说的"理想情况下零拷贝"才会变成"默认零拷贝"。
- Caller-supplied buffers——调用方提供缓冲区,进一步扩大零拷贝的适用范围。
- 线程:先协作式,后抢占式——这是最值得期待的一项。
所以现在的定位应该是:0.3.0 解决了语义正确性问题(能不能对),性能优化(快不快)还在路上。
如果你的诉求是"我要极致吞吐",现在迁移可能收益有限;如果你的诉求是"我的中间件链在 0.2 下压根写不出来",那现在就该迁。
八、迁移实战:踩坑清单
8.1 首要原则:不迁也能用
先说个可能省你半天时间的事实:用 0.3 的运行时不代表必须迁到 0.3 接口。
wasmtime serve 能在同一个进程里同时跑 0.3 和 0.2 组件,按组件自动分发——如果一个组件没有导出 0.3 的 service world,它会自动回退到 0.2 的 wasi:http/proxy world。
另外 roadmap 也明确了实现方可以用 0.3 来 polyfill 0.2:在 host 边界上把 0.2 的 import 映射到 0.3 的原生原语。这意味着老组件不改一行代码,也能间接吃到 0.3 的调度改进。
所以迁移策略应该是渐进的:先升运行时,再按需迁组件,从最痛的那个开始(通常是中间件)。
8.2 坑一:版本不对齐 → "wrong type" 报错
这是新手最容易撞的墙,而且报错信息极具误导性。
组件模型定义了 canonical interface name,理论上允许跨兼容版本链接。但现阶段并不是所有工具都支持这种版本感知的链接。
所以现在的硬性要求是:Wasmtime、wit-bindgen(Rust)、jco(JS)必须全部对准同一个 WIT 版本 0.3.0。
版本不匹配的症状是实例化时报一个莫名其妙的类型错误,而不是"版本不匹配"。debug 时很容易往错误的方向走。
自检脚本:
#!/usr/bin/env bash
set -euo pipefail
echo "=== Wasmtime ==="
wasmtime --version
echo "=== 组件实际引用的 WIT 版本 ==="
wasm-tools component wit "$1" | grep -oE '@[0-9]+\.[0-9]+\.[0-9]+' | sort -u
echo "=== Rust 侧 wit-bindgen ==="
cargo tree -i wit-bindgen 2>/dev/null | head -3 || echo "(non-rust)"
echo "=== jco ==="
jco --version 2>/dev/null || echo "(not installed)"
如果 grep 出来同时有 @0.2.x 和 @0.3.0,那你的依赖树里混进了 0.2 的绑定,先解决这个。
8.3 坑二:max-len 短返回
前面详细讲过,这里只放检查清单:
# 全仓扫描所有 random 调用点
grep -rn "get_random_bytes\|get-random-bytes\|getRandomBytes\|get_insecure_random_bytes" \
--include=*.rs --include=*.js --include=*.ts --include=*.py .
每一处都必须确认是循环填充而不是单次调用。这是安全相关的,不要省这一步。
8.4 坑三:instant 从 u64 变 s64
如果你的代码里有任何时间戳的手动序列化、数据库存储、跨语言传递,检查符号扩展:
// ❌ 可能默默截断
let ts: u64 = instant.seconds as u64;
// ✅
let ts: i64 = instant.seconds; // s64 → i64
而且现在必须处理负数(1970 前的时间戳)。如果你的下游存储是 UNSIGNED BIGINT,那是个隐患。
8.5 坑四:错误变体从"穷举"变成"非穷举"
wasi:filesystem 的 error-code 新增了 other(option<string>)。这意味着:
// ❌ 0.3 下编译不过(Rust)或者漏分支(其他语言)
match err {
ErrorCode::Access => ...,
ErrorCode::NoEntry => ...,
// ... 穷举所有已知变体
}
// ✅ 必须有兜底
match err {
ErrorCode::Access => ...,
ErrorCode::NoEntry => ...,
ErrorCode::Other(msg) => {
log::warn!("unmapped fs error: {:?}", msg);
// 别吞掉 msg,这是唯一的诊断信息来源
}
other => log::warn!("unhandled: {:?}", other),
}
同理,wasi:sockets 新增了 connection-broken。重点:如果你有重试逻辑,一定要把它和 reset/abort 分开处理。 connection-broken(EPIPE)意味着对端已经正常关闭,重试是无意义的;而 reset 可能是瞬时故障,重试有价值。0.2 时代这两者混在一起,很多重试逻辑其实是错的,只是没人发现。
8.6 坑五:写方向翻转导致的资源泄漏
这个比较隐蔽。0.3 的写路径是"你给我一个 stream,我给你一个 future"。如果你忘了关闭 stream 的写端,那个 future 永远不会 resolve。
let (tx, rx) = wit_stream::new::<u8>();
let done = stdout::write_via_stream(rx);
tx.write(b"hello".to_vec()).await?;
// ❌ 忘了 drop(tx)
done.await?; // 永远挂在这里
正确做法是显式 drop 或者用作用域控制:
let done = {
let (tx, rx) = wit_stream::new::<u8>();
let done = stdout::write_via_stream(rx);
tx.write(b"hello".to_vec()).await?;
done
}; // tx 在这里离开作用域被 drop
done.await?;
在 0.2 里对应的问题是"忘了调 outgoing-body::finish()",症状类似但报错更明显(运行时会 trap)。0.3 下它表现为静默挂起,更难查。
8.7 迁移检查清单(可直接抄)
□ Wasmtime 升到 43+(推荐 45+,等 46 拿默认开启的 async)
□ wit-bindgen / jco 版本与 WIT 0.3.0 对齐
□ 全仓 grep 掉所有 wasi:io 的 import
□ world 切换:
CLI → wasi:cli/command
HTTP服务 → wasi:http/service(原 proxy)
中间件 → wasi:http/middleware(新)
□ 所有 start-*/finish-* 调用点改成 async func
□ 所有写路径检查方向翻转 + stream 写端关闭
□ 所有 get-random-bytes 改成循环填充 ⚠️ 安全相关
□ 时间戳检查 s64 符号扩展 + 负值处理
□ 错误 match 加兜底分支,保留 other(msg) 的诊断信息
□ 重试逻辑区分 connection-broken 与 reset/abort
□ HTTP 层面处理新的 header-error::size-exceeded
□ 跑一遍 wasi-testsuite 验证运行时一致性
九、横向对比:WASI 0.3 在隔离方案里的位置
把 WASI 0.3 放到更大的图里看,它到底在跟谁竞争?
| 维度 | 容器(OCI) | V8 Isolate | 传统插件(.so/dylib) | WASI 0.3 组件 |
|---|---|---|---|---|
| 冷启动 | 100ms ~ 数秒 | ~5ms | ~1ms | 亚毫秒级 |
| 内存开销 | 数十 MB | 数 MB | ~0 | 数百 KB |
| 语言支持 | 任意 | JS/WASM | 需 ABI 兼容(实际=C/C++/Rust) | 任意可编译到 Wasm 的 |
| 隔离强度 | 内核命名空间 | 进程内软隔离 | 无 | 内存安全 + 能力隔离 |
| 跨语言互调 | 网络/IPC | FFI | C ABI(痛苦) | 原生类型化调用 |
| 异步可组合 | 天然(各自进程) | 有(同一 event loop) | 极难 | 0.3 起支持 |
| 分发 | 镜像仓库 | npm | 平台相关二进制 | 单个 .wasm 文件 |
几个判断:
vs 容器: 不是替代关系。容器适合"跑一个完整的应用",组件适合"跑一段不可信的逻辑"。真实架构里两者会共存——容器做部署单元,组件做扩展点。
vs V8 Isolate: 这是最直接的竞争。Isolate 的优势是 JS 生态和成熟度,劣势是只能跑 JS(或者退化成"在 JS 里跑 Wasm")、隔离是软的。WASI 组件的优势是任意语言 + 硬隔离,劣势是生态还年轻。
vs 传统插件 ABI: 这个是被降维打击的。任何用过 C ABI 插件系统的人都知道那有多痛:版本对不上就段错误、插件崩了宿主跟着崩、跨语言基本不可能、每个平台要单独编译。WASI 组件在这几点上全面碾压。如果你正在设计一个插件系统,现在的默认答案应该是 WASI 组件,而不是 dlopen。
0.3 带来的增量价值具体是什么? 是让"组件调组件"这条路径在异步 I/O 场景下变得可用。在 0.2 时代,你只敢让组件做纯计算(图像处理、策略求值、数据转换);一旦组件需要自己做网络/文件 I/O 并且还要被别的组件调用,架构就会崩。0.3 之后这个限制解除了。
十、什么时候该上,什么时候别上
写技术文章最没意思的结尾就是"这个技术很棒,快去用吧"。所以给个诚实的判断。
现在就该上
- 你在做插件系统 / 扩展点,需要跑第三方代码,且需要多语言支持。这是 WASI 组件最成熟的场景,0.3 让插件也能做 I/O 了。
- 你有中间件链的需求——网关、过滤器、拦截器。这是 0.3 相对 0.2 的核心增量,没有之一。
- 边缘计算 / FaaS,需要毫秒级冷启动和强隔离。
- 数据库 UDF、代理的 filter——Envoy 那类场景。
再等等
- 你在追极致吞吐。 前面说了,stream 的零拷贝优化还在 0.3.x 路线上,现在迁性能收益有限。
- 你重度依赖取消语义。 cancellation 和语言习惯的集成还没做完,现在得手写不少胶水。
- 你需要多线程。 threads 提案排在协作式之后再抢占式,还有一段路。
- 你的团队没人愿意维护 WIT。 这是真实成本——WIT 是一门要学的接口定义语言,工具链还在快速变化,出问题时能搜到的答案不多。
直接别上
- 你只是想跑个不可信的脚本,而且只用 JS。 那就 V8 isolate +
vm模块,别折腾。 - 你的应用是个单体,没有扩展点需求。 组件模型的价值在边界上,没有边界就没有价值。
- 你的运行时环境锁死在老版本。 0.3 需要 Wasmtime 43+,如果你的平台方还停在 0.1,先解决这个。
十一、总结:这次改动真正的意义
回过头看,WASI 0.3 做的事情可以用一句话概括:
把"异步"从一个可选的库,变成了 ABI 的一部分。
这个决定的代价是巨大的——整包删除 wasi:io、HTTP 接口从 9 个资源砍到 2 个、sockets 从 7 个接口合并成 2 个、所有 start-*/finish-* 全部推倒重来。任何一个已经有生产用户的标准,做这种级别的破坏性变更都需要相当的决心。
但这个代价是必须付的。因为**"能表达异步"和"能组合异步"是两回事**,而组件模型的全部价值恰恰建立在"组合"上。一个不能组合异步的组件模型,只能做纯计算插件,也就永远只是个玩具。
从更长的时间尺度看,这条路径其实很清晰:
- 0.1 解决了"Wasm 能不能访问系统资源"
- 0.2 解决了"不同语言写的模块能不能互相调用"
- 0.3 解决了"这些模块能不能在真实 I/O 场景下串起来"
- 接下来的 0.3.x 会解决"串起来之后快不快"(零拷贝、caller-supplied buffers、stream 内建优化)
- 再往后 是线程,从协作式到抢占式
每一步都在把"Wasm 只能在浏览器里跑点计算"这个刻板印象往后推一点。到了能跑抢占式多线程 + 零拷贝 I/O + 跨语言组合的那天,"通用运行时"这四个字才算真的立住。
对于我们这些写代码的人,现阶段最实际的建议是三条:
- 运行时先升上去,反正 0.2 组件还能跑,没有迁移成本
- 新写的组件直接用 0.3,特别是中间件类的
- 老组件按痛感排序迁,别搞大爆炸式重写
最后再强调一遍那个最容易被忽略、后果最严重的坑:get-random-bytes 的 len 改成了 max-len,实现可以少给你字节。所有生成密钥、nonce、salt 的地方,全部改成循环填充。
这个坑不会在测试环境暴露,只会在某个流量高峰的凌晨,给你一个 12 字节的"AES-256 密钥"。
附:速查表
WIT 类型映射(0.2 → 0.3)
pollable → future<T>
input-stream → stream<u8>
output-stream → stream<u8>(写方向翻转)
poll(list<pollable>) → await
resource.subscribe() → 调用直接返回 future
start-foo / finish-foo → foo: async func(...)
world 映射
wasi:cli/command → wasi:cli/command(run 变 async)
wasi:http/proxy → wasi:http/service
(无) → wasi:http/middleware(新增)
接口改名
wasi:clocks/wall-clock → wasi:clocks/system-clock
datetime → instant(seconds: u64 → s64)
get-random-bytes(len) → get-random-bytes(max-len) ⚠️
常用命令
# 查看组件的 WIT 接口
wasm-tools component wit app.wasm
# 组合两个组件
wac plug middleware.wasm --plug business.wasm -o app.wasm
# 跑 0.3 HTTP 服务
wasmtime serve -Sp3 -W component-model-async=y app.wasm
# 跑 0.3 CLI
wasmtime run -Sp3 -W component-model-async=y cli.wasm
# JS 组件化
jco componentize app.js --wit wit/ --world-name my-world -o app.wasm
关键版本
WASI 0.3.0 2026-06-11 发布
Wasmtime 43+ 支持 WASI 0.3
Wasmtime 44 新增 wasi:tls@0.3.0-draft;wasmtime serve 支持 0.2/0.3 自动分发
Wasmtime 45 跑最新 RC
Wasmtime 46 将默认启用 Component Model Async
jco JS 环境的 0.3 支持
点版本节奏 每两个月,当月第一个周四,release train 模式