编程 WASI 0.3 深度拆解:当 WebAssembly 决定把 async 焊进 ABI——wasi:io 整包删除、三明治问题终结,一次重写组件间异步语义的底层手术

2026-08-08 17:48:09 +0800 CST views 10

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.1Preview 1 / P1Legacycore modulePOSIX 风格的扁平函数导入,wasi_snapshot_preview1
WASI 0.2Preview 2 / P2Stable(2024-01-25)componentWIT 接口、组件模型、跨语言可组合
WASI 0.3Preview 3 / P3Stable(2026-06-11)component原生异步:async func / stream<T> / future<T>

有几个事实值得单独拎出来:

第一,0.1 到今天依然是产量最大的。 因为它简单——就是一堆 fd_readfd_writepath_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 几乎一模一样:

  1. 从某个资源上 subscribe() 拿到一个 pollable
  2. 把一堆 pollable 攒起来丢给 poll()
  3. poll() 阻塞直到至少一个 ready,返回 ready 的下标
  4. 对 ready 的那些资源做实际 I/O
  5. 回到 1

单个组件直接和 host 打交道时,这套东西工作得很好。问题出在组件被组合起来的时候。

2.2 断裂点:pollable 是实例作用域的资源

考虑一个最普通不过的场景——一个 HTTP 中间件:

组件 A(业务逻辑) → 组件 B(鉴权/日志中间件) → Host(真实网络)

B 需要把 A 的请求转发给 host,再把 host 的响应转回给 A。现在问题来了:

  • Host 给 B 一个 pollable,表示"网络数据到了"
  • B 想把这个 ready 信号转发给 A
  • pollableresource,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 空转
    }
}

结局是三选一,没有一个是好的:

  1. B 起一个忙轮询循环——CPU 白烧,而且 B 本身是被动调用的,它根本没有"自己的执行线程"来跑这个循环
  2. B 给 A 返回一个"永远 ready"的 pollable——A 每次 poll 都立刻返回,然后读到 would-block,退化成忙等
  3. 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 层:有 stringlist<T>recordvariantresource 这些高级类型

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>;

绑定生成器会把它翻译成各语言的原生异步构造:

语言生成的形态
Rustasync fn
JavaScript返回 Promise 的函数
Pythoncoroutine(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:ioWASI 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(...)

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 级别的优化。

这就是为什么方向要翻——不翻的话,中间组件永远是数据的必经之路,零拷贝无从谈起。

stdoutstderr、文件系统写、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.20.3说明
wasi:http/proxywasi: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.20.3
wall-clocksystem-clock
datetimeinstant

改名的理由是向 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>;

lenmax-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 之类还没打通。这是现阶段最明显的短板。
  • tuplefuture 等类型的特化——降低这些聚合类型的 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 坑三:instantu64s64

如果你的代码里有任何时间戳的手动序列化、数据库存储、跨语言传递,检查符号扩展:

// ❌ 可能默默截断
let ts: u64 = instant.seconds as u64;

// ✅
let ts: i64 = instant.seconds;   // s64 → i64

而且现在必须处理负数(1970 前的时间戳)。如果你的下游存储是 UNSIGNED BIGINT,那是个隐患。

8.5 坑四:错误变体从"穷举"变成"非穷举"

wasi:filesystemerror-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 的
隔离强度内核命名空间进程内软隔离内存安全 + 能力隔离
跨语言互调网络/IPCFFIC 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 之后这个限制解除了。


十、什么时候该上,什么时候别上

写技术文章最没意思的结尾就是"这个技术很棒,快去用吧"。所以给个诚实的判断。

现在就该上

  1. 你在做插件系统 / 扩展点,需要跑第三方代码,且需要多语言支持。这是 WASI 组件最成熟的场景,0.3 让插件也能做 I/O 了。
  2. 你有中间件链的需求——网关、过滤器、拦截器。这是 0.3 相对 0.2 的核心增量,没有之一。
  3. 边缘计算 / FaaS,需要毫秒级冷启动和强隔离。
  4. 数据库 UDF、代理的 filter——Envoy 那类场景。

再等等

  1. 你在追极致吞吐。 前面说了,stream 的零拷贝优化还在 0.3.x 路线上,现在迁性能收益有限。
  2. 你重度依赖取消语义。 cancellation 和语言习惯的集成还没做完,现在得手写不少胶水。
  3. 你需要多线程。 threads 提案排在协作式之后再抢占式,还有一段路。
  4. 你的团队没人愿意维护 WIT。 这是真实成本——WIT 是一门要学的接口定义语言,工具链还在快速变化,出问题时能搜到的答案不多。

直接别上

  1. 你只是想跑个不可信的脚本,而且只用 JS。 那就 V8 isolate + vm 模块,别折腾。
  2. 你的应用是个单体,没有扩展点需求。 组件模型的价值在边界上,没有边界就没有价值。
  3. 你的运行时环境锁死在老版本。 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 + 跨语言组合的那天,"通用运行时"这四个字才算真的立住。

对于我们这些写代码的人,现阶段最实际的建议是三条:

  1. 运行时先升上去,反正 0.2 组件还能跑,没有迁移成本
  2. 新写的组件直接用 0.3,特别是中间件类的
  3. 老组件按痛感排序迁,别搞大爆炸式重写

最后再强调一遍那个最容易被忽略、后果最严重的坑:get-random-byteslen 改成了 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 模式

推荐文章

Dropzone.js实现文件拖放上传功能
2024-11-18 18:28:02 +0800 CST
10个极其有用的前端库
2024-11-19 09:41:20 +0800 CST
实用MySQL函数
2024-11-19 03:00:12 +0800 CST
Nginx负载均衡详解
2024-11-17 07:43:48 +0800 CST
Rust 高性能 XML 读写库
2024-11-19 07:50:32 +0800 CST
前端如何优化资源加载
2024-11-18 13:35:45 +0800 CST
程序员茄子在线接单