编程 WASI 0.3 深度拆解:当 WebAssembly 决定把 poll 循环从用户态删掉——从 wasi:io 的消失到 stream/future 焊进 Canonical ABI 的一次协议手术

2026-08-10 04:17:16 +0800 CST views 6

WASI 0.3 深度拆解:当 WebAssembly 决定把 poll 循环从用户态删掉——从 wasi:io 的消失到 stream<T>/future<T> 焊进 Canonical ABI 的一次协议手术

2026 年 6 月 11 日,WASI 0.3.0 正式发布。这次发布最狠的一刀不是加了什么,而是删了什么:整个 wasi:io 包被移除,pollableinput-streamoutput-stream 三个跑了三年的资源类型集体下岗,它们的职责被下沉进 Component Model 的 Canonical ABI,变成了 async funcstream<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,有了 resourceworldinterface,第一次能说「这个组件需要什么、提供什么」。
  • 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,因为:

  1. A 和 B 是两个独立实例,句柄表不共享;
  2. 就算 ABI 允许传递 own<pollable>,A 拿到的也是一个它自己没法解释的 host 资源——A 的 world 里可能压根没导入 wasi:io/poll
  3. 更本质的: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-streamcheck-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 自己按节奏抽」。

为什么这样更好?三个理由:

  1. 背压天然正确。 Host 抽多快由 host 决定,guest 不需要 check-write 再自己节流。写慢了就是 stream 的生产端被 runtime 挂起,语义干净。
  2. 可组合。 一条 stream<u8> 可以原封不动地从 A 传到 B 传到 Host。0.2 的 output-stream 是 host 资源,A 拿不到 Host 的那个。
  3. 完成语义明确。 你有一个 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 pollablefuture<T>
resource input-streamstream<u8>
resource output-streamstream<u8>(传入方向)
poll(list<pollable>)对 future 做 await
资源上的 subscribe()调用直接返回 future
start-foo / finish-foo 两段式foo: async func(...)

最后一行是重灾区。0.2 里为了表达「可挂起」,到处是 start-connect / finish-connectstart-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 全部塌缩成 requestresponse 两个资源,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 的 proxy world,HTTP 服务端;
  • wasi:http/middleware —— 在 service 基础上同时 import handler,一等公民的请求路径中间件。

这才是三明治问题被解决后真正解锁的能力:中间件组合从「能写但会 hang」变成「协议原生支持」。

另外一个小改动:header-error variant 新增 size-exceeded,用于 header 超出 runtime 配置上限的情况。以前这种情况的错误码是含糊的。

5.3 wasi:sockets —— 7 个接口合并成 2 个

0.2 的 socket 接口散成七块:networkinstance-networktcptcp-create-socketudpudp-create-socketip-name-lookup

0.3 合并为:一个 types 接口(含 tcp-socketudp-socket 两个资源)+ 一个 ip-name-lookup

三个实质变化:

(1)network 资源被移除。 网络访问权限改为在 world 层面授予,不再需要把 borrow<network> 参数穿过每一个 bindconnect、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历史上只是为了非阻塞派发才写成两段式的操作(如 bindlisten)塌缩成普通 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.2WASI 0.3
wall-clocksystem-clock
datetimeinstant

对齐 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-bytesget-insecure-random-byteslen 参数改叫 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-streamcheck-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/WritableStreamfuture<T> 对上 Promise。某种意义上,Component Model 的 async 设计本来就借鉴了 Web 平台这套已经被验证过十年的模型。


七、迁移:工具链版本地狱是真实存在的

这一节是这篇文章里最「反营销」的部分,但也是最省你时间的部分。

7.1 版本要求表

工具最低版本说明
Wasmtime46+46 起 WASI 0.3 与 component-model-async 默认开启
wit-bindgen0.46+需要开启 async feature
jcolatest0.3 host 绑定在 preview3-shim
wkg0.15+更早版本无法解码 wasi:cli@0.3.0-rc-2026-03-15
Rustnightly当前 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 迁移优先级建议

不是所有代码都值得现在迁。我的判断顺序:

  1. 必须迁:中间件 / 网关 / 插件链路上的组件。三明治问题就是为你们准备的,收益最大。
  2. 值得迁:HTTP 服务端组件。九资源塌缩成二,代码量和出错面都大幅下降。
  3. 可以等:纯计算组件(图像处理、编解码、规则引擎)。它们基本不碰 I/O,wasi:io 的消失对它们没影响。0.2 组件在 0.3 runtime 上照跑不误。
  4. 重点检查:任何用了 get_random_bytes 的地方(语义变更)、任何依赖 datetime 命名的代码(改名)、任何把 instant.secondsu64 处理的地方(改 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 在设计上很克制:bindlisten 这类不需要在 host 挂起的操作保持为普通 func,只有 connect 这种真会等的才是 async func

这个设计原则值得你在自己的 WIT 里照抄:不要因为「异步看起来更现代」就把所有函数标 async。标准的判据是:这个操作在 host 侧会不会真的进入等待状态? 不会,就用普通 func。


九、十条踩坑清单

按被咬概率从高到低排:

  1. get-random-bytesmax-len 语义。可能短读,必须循环收集。密钥/nonce/session id 生成处全局搜一遍。编译器不会救你。
  2. 工具链版本不对齐。报错信息是误导性的 wrong type,实际是 Wasmtime / wit-bindgen / jco / wkg 版本错位。把版本对齐写进 CI。
  3. WIT 里写死 @0.3.0 但工具链还在 RC 快照。pin RC 版本号,直到确认工具链刷新。
  4. 以为 wasm32-wasip3 能直接用。Tier 3,无预编译产物,走 wasip2 + wit-bindgen async feature 的 reactor 模式。
  5. Rust 里想用 fn main() 写 0.3 命令组件。目前没有这条路,只能 reactor 模式导出 wasi:cli/run
  6. 忘了 instant.secondsu64 变成了 s64。类型不匹配在有些绑定层会静默截断而不是报错。
  7. 只测 N=1 的场景然后得出「0.3 没提升」的结论。0.3 的收益随组合链长度放大,单跳看不出来。
  8. 写方向翻转的心智没转过来。0.3 是「交出一条流让 host 抽」,不是「拿个洞往里塞」。硬套 0.2 的写法会写出一个自己缓冲全部 body 的中间件,把 0.3 最大的优势抵消掉。
  9. 假设 runtime 已经做到了零拷贝透传。规范允许,不代表当前实现做到了。做容量规划时按保守估计来。
  10. 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——如果你做了实测,欢迎交流数据。

推荐文章

Go语言中实现RSA加密与解密
2024-11-18 01:49:30 +0800 CST
关于 `nohup` 和 `&` 的使用说明
2024-11-19 08:49:44 +0800 CST
微信小程序开发资源汇总
2026-05-11 16:11:29 +0800 CST
浏览器自动播放策略
2024-11-19 08:54:41 +0800 CST
Nginx 反向代理
2024-11-19 08:02:10 +0800 CST
你可能不知道的 18 个前端技巧
2025-06-12 13:15:26 +0800 CST
程序员茄子在线接单