WASI 0.3 深度拆解:当 WebAssembly 决定把 poll 循环从用户态删掉——从 wasi:io 的消失到 stream<T>/future<T> 焊进 Canonical ABI 的一次协议手术
2026 年 6 月 11 日,WASI 0.3.0 正式发布。这次发布最狠的一刀不是加了什么,而是删了什么:整个
wasi:io包被移除,pollable、input-stream、output-stream三个跑了三年的资源类型集体下岗,它们的职责被下沉进 Component Model 的 Canonical ABI,变成了async func、stream<T>、future<T>三个原语。这篇文章不复述发布公告。我想讲清楚三件事:为什么非删不可(三明治问题的完整推演)、删完之后 ABI 层到底发生了什么(值语义 vs 资源语义的本质差异)、以及你现在迁移会踩到哪些坑(工具链版本地狱是真实存在的)。
一、背景:Wasm 的「最后一公里」为什么卡了三年
先给不熟悉这条线的人补上时间轴:
- 2019:WASI Preview 1(
wasi_snapshot_preview1),本质是一套 POSIX-ish 的裸函数导入,没有类型系统,没有组合能力,fd_read那一套。 - 2024 年初:WASI 0.2(Preview 2)发布,引入 WIT + Component Model,有了
resource、world、interface,第一次能说「这个组件需要什么、提供什么」。 - 2025 年 9 月:Wasm 3.0 发布,64 位内存、GC、异常处理进核心规范。
- 2026 年 6 月 11 日:WASI 0.3.0 发布,原生 async 落地。
Solomon Hykes 那条著名的推文——「如果 2008 年就有 Wasm+WASI,我们根本不需要造 Docker」——被引用了无数遍。但过去三年,任何认真拿 Wasm 做过服务端组件化的人都知道,卡点根本不在性能,也不在 GC,而在一个特别别扭的地方:
WASI 0.2 能表达异步,但不能「组合」异步。
这句话听起来很抽象。下面我用一个具体到能编译的例子把它拆开。
二、三明治问题:一次完整的失败推演
2.1 场景
假设你在做一个典型的组件化架构:
组件 A(业务逻辑) → 组件 B(鉴权/日志中间件) → Host(真实的网络栈)
这是任何一个网关、Service Mesh sidecar、插件系统都会长成的形状。A 发起一个 HTTP 调用,B 在中间做一层拦截(加个 header、打个日志、限个流),最后由 Host 真正发包。
2.2 在 WASI 0.2 里,这段代码长什么样
WASI 0.2 的 HTTP 客户端接口是这样的(简化):
// WASI 0.2 - wasi:http/outgoing-handler
handle: func(
request: outgoing-request,
options: option<request-options>
) -> result<future-incoming-response, error-code>;
// wasi:io/poll
resource pollable {
ready: func() -> bool;
block: func();
}
poll: func(in: list<borrow<pollable>>) -> list<u32>;
注意 future-incoming-response 是一个 resource,你要拿到结果,得:
// 组件 B 的伪代码(WASI 0.2)
let fut = outgoing_handler::handle(req, None)?;
let pollable = fut.subscribe(); // 拿到一个 pollable 资源
loop {
poll(&[&pollable]); // 阻塞在这里
if let Some(res) = fut.get() {
return res;
}
}
单独看,这段代码没毛病。问题出在它是组件 B 里的代码。
2.3 致命的一行:pollable 是实例作用域的资源
Component Model 里,resource 的句柄(handle)是绑定到组件实例的。Host 给 B 的那个 pollable,句柄表存在 B 的实例里。B 没有任何机制把这个句柄「转交」给 A,因为:
- A 和 B 是两个独立实例,句柄表不共享;
- 就算 ABI 允许传递
own<pollable>,A 拿到的也是一个它自己没法解释的 host 资源——A 的 world 里可能压根没导入wasi:io/poll; - 更本质的:
pollable的语义是「订阅一个就绪信号」,而就绪信号的接收者必须是那个能真正陷入等待的执行体。在 0.2 里,只有 Host 能真正 park 线程。
所以 B 只剩一条路:自己跑一个 poll 循环,等 Host 就绪了,再把结果按自己的方式交给 A。
于是就出现了这个荒谬的局面:
A 在等 B (A 的执行栈挂起,但 runtime 不知道它在等什么)
B 在 poll Host (B 必须持续占用一个执行上下文空转/阻塞)
Host 在等网卡 (唯一真正该等的地方)
中间层 B 变成了一个纯粹为了转发唤醒信号而存在的 event loop。这就是 Bytecode Alliance 官方文档里说的 sandwich problem(三明治问题):异步语义只能描述「一跳」,跨不过第二跳。
2.4 它带来的实际成本
我把它拆成四条,每条都是能在生产里量到的:
(1)并发度塌缩。 B 如果用 poll 阻塞式等待,那 B 的这个实例在等待期间无法处理其他请求。要并发,B 必须自己实现一套多路复用 + 状态机,把所有在途请求的 pollable 攒进一个 list<pollable> 里一起 poll。每个中间件组件都要重写一遍 epoll——这不是抽象泄漏,这是抽象崩塌。
(2)唤醒链丢失。 实践中,很多中间件作者根本不知道要转发唤醒,写出来的代码在压测时表现为「偶发 hang 住」或者「延迟出现莫名其妙的阶梯」。因为唤醒被吞了,只能等下一次外部事件把 B 顺带盘活。
(3)延迟叠加。 假设组合链有 N 跳,每一跳的 poll 循环都有自己的调度粒度 δ,端到端延迟的抖动项是 O(N·δ),而不是理想的 O(δ)。链路越长,尾延迟越丑。
(4)背压无法传导。 output-stream 的 check-write 返回可写字节数,这个背压信号只在相邻两跳之间有效。A 不知道 Host 的 socket buffer 满了,它只知道 B 慢。中间层要么自己缓冲(内存爆炸),要么自己实现背压转发(又一遍轮子)。
这四条加起来的结论很清楚:在 0.2 下,"组件组合"这个 Component Model 最核心的卖点,一碰到 I/O 就废了。 而服务端的一切都是 I/O。
三、0.3 的解法:把调度权从组件手里收回给 runtime
WASI 0.3 的思路极其干脆:既然中间层不该管调度,那就别让它有机会管。
具体做法是把异步原语从「库里的资源」下沉成「ABI 里的值」。三个新原语:
3.1 async func
// WASI 0.2
handle: func(request: incoming-request, response-out: response-outparam);
// WASI 0.3
handle: async func(request: request) -> result<response, error-code>;
一个函数被标记为 async,意味着它可以在返回前挂起。挂起和恢复由 Canonical ABI 处理,guest 看不到 pollable,host 看不到 poll 循环。
绑定生成器会翻译成各语言的原生 async 形态:Rust 的 async fn、JavaScript 的 Promise、Python 的协程。这一点很关键——它意味着你终于可以直接用 tokio 那套心智模型写 Wasm 组件,而不是先学一套 WASI 特有的 poll 编程。
3.2 stream<T>:从资源变成值
这是整场手术里最核心的一刀。
// WASI 0.2:input-stream 是 resource
resource input-stream {
read: func(len: u64) -> result<list<u8>, stream-error>;
subscribe: func() -> pollable;
}
// WASI 0.3:stream<T> 是 Canonical ABI 的值类型
read-via-stream: func(offset: filesize) -> tuple<stream<u8>, future<result<_, error-code>>>;
resource 和 value 的差别有多大? 我列个对比表,这决定了架构上能做什么:
| 维度 | resource input-stream(0.2) | stream<u8>(0.3) |
|---|---|---|
| 句柄归属 | 绑定到单个组件实例的句柄表 | ABI 值,可跨实例传递 |
| 能否作为返回值原样透传 | 可以传 own,但接收方需要有对应 world 导入 | 可以,接收方只需要知道 stream<u8> 这个类型 |
| 唤醒传播 | 需要中间层跑 event loop 转发 | runtime 负责,中间层零参与 |
| 缓冲区归属 | 每跳一次 read 拷贝到 guest 线性内存 | 可由 runtime 决定拷贝时机,具备零拷贝直通的空间 |
| 背压 | 逐跳 check-write 手工转发 | 由 runtime 在链路上统一维护 |
最后两行是性能上真正值钱的地方。当 stream<u8> 是一个 ABI 值,从 Host 经过 B 透传给 A 时,runtime 知道这条流的完整拓扑。它可以决定:这段字节要不要真的落进 B 的线性内存?如果 B 只是透传而不检查内容,那完全可以跳过。0.2 时代这是不可能的——每一跳都是一次 read(len) -> list<u8>,一次实打实的 memcpy 进 guest 内存。
3.3 future<T>
write-via-stream: func(data: stream<u8>) -> future<result<_, error-code>>;
单值的异步完成句柄,同样是值而非资源。有个细节值得注意:返回 future<T> 的同步函数不能阻塞。它必须立刻返回一个未决的 future,调用方需要结果时再 await。这个约束把「什么时候真正等待」的决定权交还给了调用方,而不是被接口签名绑死。
四、两个反复出现的设计模式
读 0.3 的 WIT,你会发现两个模式贯穿了几乎所有接口。理解了这两个,剩下的接口你自己就能推。
4.1 stream + 终结 future(读方向)
read-via-stream: func() -> tuple<stream<u8>, future<result<_, error-code>>>;
返回一个二元组:数据通道 + 完成句柄。这两半是独立的。
为什么要拆开?因为 0.2 有个长期的痛点:你必须把流读完才知道它是不是出错了。input-stream.read() 返回 stream-error,如果你中途 drop 掉这个流(比如客户端断连、或者你只需要前 1KB 做 sniff),那这次操作到底是成功还是失败,你无从得知。
0.3 拆开之后:
- 你可以贪婪地消费整条流;
- 也可以采样几个 chunk 就丢掉;
- 无论哪种,
future都会在操作终结时 resolve,携带成功或失败的结果。
这个形状出现在 stdin、文件读、TCP 收、目录列举——全套。
read-directory 是个很好的例子,它顺手解决了「大目录列举要不要分页」这个老问题:
read-directory: func() -> tuple<stream<directory-entry>, future<result<_, error-code>>>;
注意泛型参数是 directory-entry 而不是 u8——stream<T> 是类型化的通道,不是字节管道。这意味着 entry 的序列化/反序列化由 Canonical ABI 负责,你不用自己在两端约定编码格式。
4.2 写方向翻转
这个改动第一次看会觉得别扭,想通了会觉得非常对。
// WASI 0.2:拿到一个 host 的 output-stream,往里推
get-stdout: func() -> output-stream;
// WASI 0.3:把数据作为 stream 传进去,拿回一个完成 future
write-via-stream: func(data: stream<u8>) -> future<result<_, error-code>>;
方向翻转了。 0.2 是「host 给你一个洞,你往里塞」;0.3 是「你给 host 一条流,host 自己按节奏抽」。
为什么这样更好?三个理由:
- 背压天然正确。 Host 抽多快由 host 决定,guest 不需要
check-write再自己节流。写慢了就是 stream 的生产端被 runtime 挂起,语义干净。 - 可组合。 一条
stream<u8>可以原封不动地从 A 传到 B 传到 Host。0.2 的output-stream是 host 资源,A 拿不到 Host 的那个。 - 完成语义明确。 你有一个
future明确告诉你「写完了/写失败了」,而不是靠 flush + drop 猜。
代价是心智模型要转:你不再「调用 write」,你「交出一条流」。对于习惯了 w.Write(buf) 的人,这需要几天适应期。
五、接口逐条 diff:哪些改动会真的让你的代码编不过
5.1 wasi:io —— 整包删除
没有 0.3 版本的 wasi:io。 映射关系:
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(...) |
最后一行是重灾区。0.2 里为了表达「可挂起」,到处是 start-connect / finish-connect、start-listen / finish-listen 这种两段式调用。这些全部塌缩成一次 async func。
5.2 wasi:http —— 九个资源塌缩成两个
改动最激进的就是 HTTP。0.2 的资源矩阵是这样的:
incoming-request / outgoing-request
incoming-response / outgoing-response
incoming-body / outgoing-body
future-trailers
future-incoming-response
response-outparam
九个资源,一个 incoming/outgoing × request/response/body 的笛卡尔积,外加三个补丁类型。任何写过 wasi:http 0.2 handler 的人都记得 response-outparam::set() 那个反人类的写法。
0.3 全部塌缩成 request 和 response 两个资源,body 是 stream<u8>,trailers 是一个 future。
// WASI 0.2 handler
handle: func(request: incoming-request, response-out: response-outparam);
// WASI 0.3 handler
handle: async func(request: request) -> result<response, error-code>;
// WASI 0.3 client
send: async func(request: request) -> result<response, error-code>;
看到关键点了吗?handler 和 client 的签名一模一样。
这不是巧合,这是刻意设计。签名同构意味着中间件可以被表达为一个既导入又导出 handler 的组件。所以 0.3 新增了一个 world:
wasi:http/service—— 替代 0.2 的proxyworld,HTTP 服务端;wasi:http/middleware—— 在service基础上同时 importhandler,一等公民的请求路径中间件。
这才是三明治问题被解决后真正解锁的能力:中间件组合从「能写但会 hang」变成「协议原生支持」。
另外一个小改动:header-error variant 新增 size-exceeded,用于 header 超出 runtime 配置上限的情况。以前这种情况的错误码是含糊的。
5.3 wasi:sockets —— 7 个接口合并成 2 个
0.2 的 socket 接口散成七块:network、instance-network、tcp、tcp-create-socket、udp、udp-create-socket、ip-name-lookup。
0.3 合并为:一个 types 接口(含 tcp-socket 和 udp-socket 两个资源)+ 一个 ip-name-lookup。
三个实质变化:
(1)network 资源被移除。 网络访问权限改为在 world 层面授予,不再需要把 borrow<network> 参数穿过每一个 bind、connect、DNS 查询。这是能力式安全(capability-based security)的粒度调整——权限从「每次调用都要出示令牌」变成「实例化时一次性授予」。对代码整洁度是巨大改善,但也意味着你要在组合层面更认真地对待 world 的权限边界。
(2)两段式调用塌缩:
// WASI 0.2
start-connect: func(network: borrow<network>, remote-address: ip-socket-address) -> result<_, error-code>;
finish-connect: func() -> result<tuple<input-stream, output-stream>, error-code>;
// WASI 0.3
connect: async func(remote-address: ip-socket-address) -> result<_, error-code>;
注意一个细节:不是所有两段式都变成了 async func。真的需要在 host 里挂起的操作(如 connect)变成 async func;历史上只是为了非阻塞派发才写成两段式的操作(如 bind、listen)塌缩成普通 func。这个区分很讲究——它避免了给本来就不会阻塞的调用套上 async 的开销。
(3)listen 返回一条 socket 流:
listen: func() -> result<stream<tcp-socket>, error-code>;
accept 循环没了。你拿到的是一条 stream<tcp-socket>,直接消费就行。这个设计对写过 Go for { conn, _ := ln.Accept(); go handle(conn) } 的人来说会心一笑——但它比 Go 的版本多了一个东西:这条 stream 本身可以被传给另一个组件。你可以让组件 A 负责 listen 和 TLS 终止,把 socket 流交给组件 B 处理业务,而这在 0.2 里做不到。
新增错误码 connection-broken,对应 POSIX 的 EPIPE,用来区分「对端已关闭导致的写失败」和「reset / abort 等其他异常」。这个区分在做重试策略时很有用:EPIPE 通常意味着可以安全重连重试,reset 则不一定。
5.4 wasi:cli
run: async func() -> result;
run 变成 async 了。stdin/stdout/stderr 全部改用 stream<u8> + stream-plus-future 模式。
新增一个共享的 types 接口,定义跨 CLI 接口复用的 error-code 枚举。
exit 接口现在同时暴露 exit(status: result) 和 exit-with-code(status-code: u8)。后者是 2026 年 6 月从 Phase 2 提升进稳定 0.3 接口的——终于可以返回具体退出码了,写 CLI 工具的人应该会很高兴。
5.5 wasi:filesystem
几乎所有 descriptor 方法都变成了 async func。流式读写走 stream-plus-future,目录列举返回 stream<directory-entry>。
error-code 新增兜底的 other(option<string>)。这个改动看着不起眼,但它承认了一个现实:POSIX errno 的枚举永远不可能穷尽所有宿主的错误情况。以前遇到映射不上的错误只能硬塞一个最接近的变体,现在可以带上原始描述串。做错误排查的人会感谢这个改动。
5.6 wasi:clocks —— 一次改名手术
| WASI 0.2 | WASI 0.3 |
|---|---|
wall-clock | system-clock |
datetime | instant |
对齐 POSIX 和 Rust std::time 的命名习惯。
更实质的改动:instant record 的秒字段从 u64 改成了 s64,支持纪元前时间戳。如果你在做历史数据处理、天文、地质、或者任何需要表示 1970 年之前时间的场景,这个改动是刚需。
subscribe-instant / subscribe-duration(返回 pollable)被替换为:
wait-until: async func(when: mark);
wait-for: async func(how-long: duration);
wait-for 就是 async sleep。终于不用为了睡 100ms 而构造一个 pollable 再 poll 了。
5.7 wasi:random —— 一个会咬人的语义变更
表面上只是改名:get-random-bytes 和 get-insecure-random-bytes 的 len 参数改叫 max-len。
但这是语义变更,不是改名。 max-len 意味着实现可以返回比请求更少的字节。
这意味着所有这样的代码都错了:
// 危险:0.3 下可能拿到少于 32 字节
let key = random::get_random_bytes(32);
assert_eq!(key.len(), 32); // 可能 panic
正确写法是循环收集:
fn fill_random(n: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(n);
while out.len() < n {
let chunk = random::get_random_bytes((n - out.len()) as u64);
if chunk.is_empty() {
// 实现返回空说明当前无法提供更多熵,需要根据场景决定退避或报错
panic!("entropy source exhausted");
}
out.extend_from_slice(&chunk);
}
out
}
这是整个 0.3 里最容易被静默漏掉的坑:编译器不会报错,测试环境大概率一次就返回够,只有在高并发抽熵或者受限宿主上才会偶发短读。如果你的代码里有生成密钥、nonce、session id 的地方,迁移时第一件事就是全局搜 get_random_bytes。
六、代码实战
6.1 一个 0.3 的 HTTP service 组件
先写 WIT:
// wit/service.wit
package example:svc;
world service {
include wasi:http/service@0.3.0;
}
wasi:http/service 已经把导出的 handler、以及 clocks / random / cli stdio / HTTP 客户端这些导入都打包好了。用 wkg 拉依赖:
# wkg 0.15+ 才能正确解码 wasi:cli@0.3.0 系列包
wkg wit fetch
Cargo.toml:
[package]
name = "svc"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
# 0.3 绑定生成需要开 async feature
wit-bindgen = { version = "0.58", features = ["async"] }
Rust 侧(形态如下,具体生成的 trait 路径以你本地 wit-bindgen 版本的输出为准,0.3 绑定层还在快速迭代):
mod bindings {
use super::Component;
wit_bindgen::generate!();
export!(Component);
}
use bindings::exports::wasi::http::handler::Guest;
use bindings::wasi::http::types::{Request, Response, ErrorCode};
struct Component;
impl Guest for Component {
async fn handle(request: Request) -> Result<Response, ErrorCode> {
// 1) 读 header —— 同步的,不需要 await
let path = request.path_with_query().unwrap_or_default();
// 2) body 是 stream<u8>,消费它是异步的
let body = request.consume_body(); // -> stream<u8>
// 3) 构造响应
let (tx, rx) = new_stream::<u8>(); // 生成 stream 的两端
let resp = Response::new(rx);
resp.set_status_code(200)?;
// 4) 边算边推,不需要先攒完整个 body
spawn(async move {
let mut total = 0usize;
while let Some(chunk) = body.next().await {
total += chunk.len();
tx.write(chunk).await;
}
tx.write(format!("\n-- {path}: {total} bytes --").into_bytes()).await;
drop(tx); // drop 即关闭流,对端的终结 future 随之 resolve
});
Ok(resp)
}
}
对比一下 0.2 版本你要写的东西——response-outparam::set()、手动 outgoing-body::write() 拿 output-stream、check-write 算可写窗口、finish() 塞 trailers、全程还要照顾 pollable——代码量差三倍不止,而且 0.2 版本里每一处 poll 都是一个潜在的 hang 点。
6.2 中间件组合:0.3 真正的杀手锏
这是 0.2 做不到的东西。写一个鉴权中间件:
// wit/auth.wit
package example:auth;
world auth-middleware {
include wasi:http/middleware@0.3.0; // 同时 import 和 export handler
}
use bindings::exports::wasi::http::handler::Guest as Export;
use bindings::wasi::http::handler as inner; // 这是 import 的下游 handler
struct Component;
impl Export for Component {
async fn handle(request: Request) -> Result<Response, ErrorCode> {
// 前置:鉴权
let token = request.headers().get("authorization");
if !verify(token) {
let resp = Response::new(empty_stream());
resp.set_status_code(401)?;
return Ok(resp);
}
// 关键:直接 await 下游组件,body stream 原样透传
// 中间件不需要跑任何 event loop,不需要缓冲 body
let mut resp = inner::handle(request).await?;
// 后置:加 header
resp.headers().append("x-authed", b"1")?;
Ok(resp)
}
}
组合:
wac plug auth.wasm --plug svc.wasm -o composed.wasm
请仔细看这段代码里没有什么:
- 没有 poll 循环
- 没有 body 缓冲(
request里的stream<u8>是原样传给下游的,中间件根本没碰过那些字节) - 没有唤醒转发逻辑
- 没有背压处理
这四样在 0.2 里全都要你自己写,而且写错了压测才发现。现在它们是 runtime 的职责。
这一点值得单独强调:中间件不缓冲 body,意味着一个上传大文件的请求穿过五层中间件,字节数据不需要在五份线性内存里各拷一遍。0.2 时代每层都是 read(len) -> list<u8>,五层就是五次 memcpy 加五份峰值内存。对于网关类场景,这个差别是数量级的。
6.3 读写实战:stream-plus-future 的正确姿势
// 读一个文件,只 sniff 前 512 字节,然后决定要不要继续
let (data, done) = descriptor.read_via_stream(0);
let mut head = Vec::new();
while head.len() < 512 {
match data.next().await {
Some(chunk) => head.extend_from_slice(&chunk),
None => break,
}
}
if !looks_like_png(&head) {
drop(data); // 提前丢弃流
// 关键:即使提前 drop,done 依然会 resolve
match done.await {
Ok(()) => log::info!("read terminated cleanly"),
Err(e) => log::warn!("read failed: {e:?}"),
}
return Err(NotPng);
}
// 继续消费剩余部分...
这段在 0.2 里写不出来。 0.2 的 input-stream 一旦 drop,你就永远不知道这次读是正常结束还是底层 I/O 出错了。
写方向:
let (tx, rx) = new_stream::<u8>();
let done = descriptor.write_via_stream(rx, 0); // 立刻返回 future,不阻塞
for chunk in source {
tx.write(chunk).await; // 背压在这里生效:host 抽不动,这里就挂起
}
drop(tx);
done.await?; // 等待落盘完成
6.4 TCP:告别 accept 循环
let sock = TcpSocket::new(IpAddressFamily::Ipv4)?;
sock.bind(local_addr)?; // 普通 func,不 await
let conns = sock.listen()?; // -> stream<tcp-socket>
while let Some(conn) = conns.next().await {
spawn(handle_conn(conn));
}
对比 0.2 的 start-listen / finish-listen / accept / subscribe / poll 五件套,代码密度差异一目了然。
6.5 运行
# Wasmtime v44 起支持,v45 跑最新 RC,v46 起 0.3 + component-model-async 默认开启
wasmtime serve -Sp3 -W component-model-async=y composed.wasm
# 对于不导出 0.3 service world 的组件,wasmtime serve 会自动回落到
# WASI 0.2 的 wasi:http/proxy world —— 同一个二进制里 0.2/0.3 混跑,按组件分派
wasmtime serve legacy-p2-component.wasm
这个「同一个 binary 双版本分派」的设计非常务实。它意味着你不需要一次性把整个组件仓库都迁完,可以一个服务一个服务地推。
6.6 JavaScript 侧
jco 的 0.3 host 绑定在 preview3-shim 包里。JS 这边的映射最自然——async func 直接就是返回 Promise 的函数,stream<T> 对上 ReadableStream/WritableStream,future<T> 对上 Promise。某种意义上,Component Model 的 async 设计本来就借鉴了 Web 平台这套已经被验证过十年的模型。
七、迁移:工具链版本地狱是真实存在的
这一节是这篇文章里最「反营销」的部分,但也是最省你时间的部分。
7.1 版本要求表
| 工具 | 最低版本 | 说明 |
|---|---|---|
| Wasmtime | 46+ | 46 起 WASI 0.3 与 component-model-async 默认开启 |
| wit-bindgen | 0.46+ | 需要开启 async feature |
| jco | latest | 0.3 host 绑定在 preview3-shim 包 |
| wkg | 0.15+ | 更早版本无法解码 wasi:cli@0.3.0-rc-2026-03-15 包 |
| Rust | nightly | 当前 stable 捆绑的 wasm-component-ld 太老,处理不了 wit-bindgen 0.58 的 0.3 输出 |
7.2 三个必踩的坑
坑一:版本必须严格对齐,否则报「wrong type」。
Component Model 定义了 canonical interface name,理论上组件可以跨兼容版本链接。但目前的工具链还没有普遍支持这套版本感知链接。所以 Wasmtime、wit-bindgen、jco 必须统统指向同一个 WIT 版本。
不对齐的表现是实例化时报一堆 wrong type 或者 no exported instance named wasi:cli/run@0.2.6。这个报错信息极具误导性——它会让你以为是代码写错了,实际上是工具链版本错位。
坑二:0.3.0 正式版发布了,但工具链一度还停在 RC 快照。
在 WASI 0.3.0(2026-06-11)发布的时间点上,wit-bindgen 0.58 和 Wasmtime 45 装的还是 0.3.0-rc-2026-03-15 这个快照的 WIT。如果你的 WIT 里写死 @0.3.0,运行时就会报找不到导出实例。
结论:在你的工具链确认刷新到正式版之前,WIT 里 pin 那个 RC 版本号,而不是 0.3.0。 这条一定要写进你的 CI 检查里,否则某次工具链升级会让你的构建莫名其妙地挂掉。
坑三:Rust 的 wasm32-wasip3 target 目前是 Tier 3,没有预编译产物。
想直接编到 wasm32-wasip3?你得从源码构建 std。官方文档给的推荐路径是:继续编到 wasm32-wasip2,用 wit-bindgen 的 async feature 来生成 0.3 绑定,走 library/reactor 模式。
这也意味着:Rust 目前没有 0.3 版本的 fn main() 命令组件惯用写法。你想写一个 0.3 的 CLI 工具,只能用 reactor 模式导出 wasi:cli/run,不能靠 _start。
[lib]
crate-type = ["cdylib"]
7.3 迁移优先级建议
不是所有代码都值得现在迁。我的判断顺序:
- 必须迁:中间件 / 网关 / 插件链路上的组件。三明治问题就是为你们准备的,收益最大。
- 值得迁:HTTP 服务端组件。九资源塌缩成二,代码量和出错面都大幅下降。
- 可以等:纯计算组件(图像处理、编解码、规则引擎)。它们基本不碰 I/O,
wasi:io的消失对它们没影响。0.2 组件在 0.3 runtime 上照跑不误。 - 重点检查:任何用了
get_random_bytes的地方(语义变更)、任何依赖datetime命名的代码(改名)、任何把instant.seconds当u64处理的地方(改s64)。
WASI 0.3 相对 0.2 是纯增量的。0.3 runtime 可以在 host 边界上把 0.2 的导入 polyfill 成 0.3 原语。不迁移不会掉队,只是拿不到组合能力。
八、性能:先说清楚什么是可信的,什么是我不知道的
我先把话说在前面:截至写这篇文章时,我没有看到 WASI 0.3 vs 0.2 的权威官方 benchmark 数据。 网上流传的一些「Wasm 启动 8ms vs 容器 420ms」之类的对比,量的是 Wasm 对容器,不是 0.3 对 0.2,别混为一谈。
所以这一节我不给数字,给成本模型和自测方法。
8.1 理论上 0.3 该赢在哪
设组合链长度为 N(N 跳组件),单跳 poll 调度粒度为 δ,body 大小为 S。
延迟抖动项:
- 0.2:每一跳都有自己的 poll 循环,抖动 ≈ O(N·δ)
- 0.3:runtime 统一调度,抖动 ≈ O(δ)
N=1 时两者没差别。这就是为什么很多人试了觉得"0.3 没变快"——他们测的是单组件场景。 0.3 的收益随 N 线性放大,链路越长越明显。
内存拷贝量:
- 0.2:每一跳
read(len) -> list<u8>都是一次实拷贝,总量 ≈ N·S - 0.3:透传的
stream<u8>理论上允许 runtime 跳过不必要的拷贝,总量下界 ≈ S
注意我用了「理论上允许」——这取决于具体 runtime 的实现程度,不是规范保证。Wasmtime 的实现在持续演进中,别假设它已经做到了理想的零拷贝。
并发度:
- 0.2:中间件要并发,必须自己实现多路复用状态机
- 0.3:runtime 管调度,中间件写成
async fn天然并发
这一条不是性能优化,是能力差异。0.2 下大部分人写的中间件根本没做多路复用,实际并发度是 1。
8.2 建议的自测方法
如果你要做 A/B 对比,注意这几点,否则测出来的数字没有意义:
# 1. 链路长度必须是变量,至少测 N=1,3,5
# 只测 N=1 是在浪费时间
for n in 1 3 5; do
build_chain $n # 组合 n 层 passthrough 中间件
wasmtime serve -Sp3 -W component-model-async=y chain-$n-p3.wasm &
# 2. 关注 P99/P999,不是 avg —— 唤醒链丢失表现为尾延迟
# 3. 并发压力要足够,单并发测不出 poll 循环的并发度塌缩
wrk -t8 -c256 -d60s --latency http://127.0.0.1:8080/
done
# 4. body 大小要分档:小 body 测调度开销,大 body 测拷贝开销
# 1KB / 64KB / 4MB 三档能覆盖大部分场景
四个关键点重复一遍:变链路长度、看尾延迟、加并发、分 body 大小档位。 少任何一个,你测出来的都是噪音。
8.3 一个容易被忽略的成本
0.3 不是白吃的午餐。async func 的挂起/恢复需要 runtime 维护任务状态,这本身有开销。对于永远不会真的挂起的操作,套 async 是负优化。
这也是为什么 0.3 在设计上很克制:bind、listen 这类不需要在 host 挂起的操作保持为普通 func,只有 connect 这种真会等的才是 async func。
这个设计原则值得你在自己的 WIT 里照抄:不要因为「异步看起来更现代」就把所有函数标 async。标准的判据是:这个操作在 host 侧会不会真的进入等待状态? 不会,就用普通 func。
九、十条踩坑清单
按被咬概率从高到低排:
get-random-bytes的max-len语义。可能短读,必须循环收集。密钥/nonce/session id 生成处全局搜一遍。编译器不会救你。- 工具链版本不对齐。报错信息是误导性的
wrong type,实际是 Wasmtime / wit-bindgen / jco / wkg 版本错位。把版本对齐写进 CI。 - WIT 里写死
@0.3.0但工具链还在 RC 快照。pin RC 版本号,直到确认工具链刷新。 - 以为
wasm32-wasip3能直接用。Tier 3,无预编译产物,走 wasip2 + wit-bindgen async feature 的 reactor 模式。 - Rust 里想用
fn main()写 0.3 命令组件。目前没有这条路,只能 reactor 模式导出wasi:cli/run。 - 忘了
instant.seconds从u64变成了s64。类型不匹配在有些绑定层会静默截断而不是报错。 - 只测 N=1 的场景然后得出「0.3 没提升」的结论。0.3 的收益随组合链长度放大,单跳看不出来。
- 写方向翻转的心智没转过来。0.3 是「交出一条流让 host 抽」,不是「拿个洞往里塞」。硬套 0.2 的写法会写出一个自己缓冲全部 body 的中间件,把 0.3 最大的优势抵消掉。
- 假设 runtime 已经做到了零拷贝透传。规范允许,不代表当前实现做到了。做容量规划时按保守估计来。
wkg版本过老。0.15 以下解码不了 0.3 系列包,报错同样不直观。
十、总结:这次手术到底改变了什么
回到开头那个问题:为什么 Wasm 的「Docker 时刻」拖了这么久?
我的答案是:因为 Docker 的核心从来不是隔离,是组合。 FROM 一层层叠上去、docker-compose 把服务串起来、Kubernetes 把 Pod 编排起来——容器生态的全部价值都建立在「小单元可以可靠地组合成大系统」这个前提上。
WASI 0.2 给了 Wasm 完整的类型系统和接口定义能力,看起来万事俱备。但它在异步这个点上留了个洞:组件能被组合,但组合起来之后 I/O 就不对了。 而服务端的一切都是 I/O。
WASI 0.3 补的就是这个洞。方法不是加功能,而是做减法:删掉 wasi:io,删掉 pollable,删掉两段式调用,删掉用户态的 poll 循环,把调度权从组件手里收回给 runtime。
这个决策的哲学和 Zig 0.16 把 async/await 从语法里删掉、改成 Io 接口参数是同一个方向——异步不该是语言/库层面的语法糖,它是运行时的调度职责。两个项目在 2026 年前后各自独立走到了这个结论,这本身就挺说明问题。
现在该做什么
如果你在做网关、中间件、插件系统:现在就该试。你踩了三年的坑就是为这个版本准备的。先起一个三层 passthrough 中间件链,对比一下 0.2 和 0.3 的 P99,你会有直观感受。
如果你在做 FaaS / 边缘计算:值得投入。冷启动本来就是 Wasm 的强项,0.3 补上了 I/O 组合能力之后,「一次发布、部署到无限多个终端节点」这个叙事第一次是完整的。
如果你只是好奇:装 Wasmtime 46,wasmtime serve -Sp3 -W component-model-async=y 跑个 hello world。半小时的事。
如果你手上有一堆 0.2 组件在跑:别急。0.3 是纯增量,0.2 组件在 0.3 runtime 上照跑,wasmtime serve 会按组件分派。按上面第七节的优先级排期,先迁中间件,最后迁纯计算组件。
还没解决的问题
也别把 0.3 说成银弹。几个真实存在的缺口:
- 工具链成熟度明显落后于规范。Rust 侧还要 nightly,
wasm32-wasip3还是 Tier 3,版本对齐要手工维护。这些会在几个月内改善,但现在确实扎手。 - 版本感知链接还没普遍落地。Component Model 规范里定义了 canonical interface name 来支持跨兼容版本链接,但工具还没跟上。这意味着现阶段你只能全链路 pin 同一个版本。
- 零拷贝是规范允许的空间,不是已交付的能力。runtime 实现还在演进。
- 调试体验。异步跨组件调用链的可观测性目前还很原始。链路一长,出问题定位起来不轻松。
但方向是对的。删掉一个包、砍掉九个资源里的七个、把七个接口合成两个——一个协议敢做这么大的减法,通常说明设计者真的想明白了。
本文技术细节以 Bytecode Alliance 官方文档(wasi.dev / component-model.bytecodealliance.org)为准。WASI 0.3.0 于 2026 年 6 月 11 日发布,工具链仍在快速迭代,具体版本要求请以你安装时的官方文档为准。文中未给出 0.3 vs 0.2 的性能数字,因为我没有找到可信的权威 benchmark——如果你做了实测,欢迎交流数据。