Rust + WebAssembly 全链路解析
第13章 WASI Preview 2 与 Wasmtime 运行时
"Simplicity is prerequisite for reliability." — Edsger Dijkstra
13.1 从 Preview 1 到 Preview 2:执行模型的换代
WASI Preview 2(即 WASI 0.2.0;WASI 子组于 2024 年 1 月 24 日投票通过,Wasmtime 17 于次日发布并默认启用)不是 Preview 1 的 API 翻新——它是执行模型的根本性换代。
先看 Preview 1 的三个硬伤。第一,fd_read 在核心模块层面的签名是 (fd: i32, iovs_ptr: i32, iovs_len: i32, nread_ptr: i32) -> i32——调用者传入 i32 指针指向内存中的 scatter/gather 向量,读到的字节数写回 nread_ptr 指向的内存,返回值是 errno。这个 API 和 POSIX 的 readv() 几乎一一对应——而 POSIX 的设计源于 1970 年代的 C 语言约束。WASM 的类型系统有能力表达更精确的接口,但 Preview 1 完全浪费了这种能力。
第二,Preview 1 的 fd 是整数——文件描述符、目录描述符、socket 描述符共享同一个整数空间。这意味着你可以在一个 socket fd 上调用 fd_readdir,或者在目录 fd 上调用 sock_send——编译时完全不报错,运行时才返回错误码。这类 bug 在生产系统中极难定位——因为它只在特定调用序列下触发。
第三,Preview 1 没有异步调用模型。所有 API 都是同步调用,fd_read 会阻塞跑它的宿主线程;P1 能做的只是 poll_oneoff——一次性等待若干 fd 就绪或时钟到期(crates/wasi/witx/p1/wasi_snapshot_preview1.witx:429),外加 fdflags 里的 nonblock,能等待的只有 fd 和时钟,而 P1 又创建不了 socket(第 12 章 §12.7)。对于需要同时处理多个连接的 HTTP 服务器,这等于没有可用的异步 I/O——第 12 章已经指出,这是服务器端 WASM 最大的缺失。
三个核心变化:
从裸模块到组件:Preview 1 的产物是裸 .wasm 模块——直接暴露 i32 参数的导入/导出函数。Preview 2 的产物是组件(Component)——在裸模块外包了一层 WIT 类型信息,函数签名是类型安全的(string、list<u8>、result<T, E>),不再是原始的 i32 指针+长度。
从 fd 整数到类型化资源:Preview 1 的文件描述符是 i32——文件、目录、socket 共享同一个整数空间。Preview 2 的 descriptor(文件系统句柄)、tcp-socket、input-stream/output-stream 是互不相同的资源类型——编译时就能区分,不可能把 socket 当文件句柄传。注意文件和目录共用 descriptor 一种资源(WIT 里没有单独的目录资源类型),对目录调用 write 仍是运行时返回 is-directory 错误码。
从同步阻塞到异步轮询:Preview 1 的 I/O 调用都是同步的,等待只能靠 poll_oneoff。Preview 2 把「可等待」抽象成统一的 pollable 资源——流、定时器、socket、HTTP 响应都能交出 pollable,由 wasi:io/poll 一起等待;在异步嵌入(call_async)下,模块等待 I/O 时宿主可以调度其他任务。
flowchart LR
subgraph "Preview 1"
A["裸 .wasm 模块"] --> B["fd 整数 (i32)"]
B --> C["同步阻塞 I/O"]
C --> D["单体 wasi_snapshot_preview1"]
end
subgraph "Preview 2"
E["组件 (.wasm + WIT)"] --> F["类型化资源 (descriptor)"]
F --> G["异步 poll + 流"]
G --> H["模块化 wasi:cli / filesystem / http / ..."]
end
A -.->|"换代"| E
B -.->|"换代"| F
C -.->|"换代"| G
D -.->|"换代"| H
style A fill:#dc2626,color:#fff
style B fill:#dc2626,color:#fff
style C fill:#dc2626,color:#fff
style D fill:#dc2626,color:#fff
style E fill:#10b981,color:#333
style F fill:#10b981,color:#333
style G fill:#10b981,color:#333
style H fill:#10b981,color:#333
13.2 关键接口详解
Preview 2 把 WASI 拆成多个独立的接口包(package),每个包定义一组相关能力。组件只导入需要的接口,导入清单本身就是一份能力声明。这和 Preview 1 的单体 wasi_snapshot_preview1 模块截然不同——Preview 1 的所有函数(文件、socket、时钟、随机数……)都在一个导入模块里。但要注意:导入 wasi_snapshot_preview1 并不等于自动获得所有能力——P1 同样是能力安全的,没有预打开目录就碰不到任何文件(第 12 章 §12.2)。P1/P2 的区别在句柄与接口形式,不在「有没有能力安全」。
Preview 2 的接口拆分让最小权限多了一层:一个只做 HTTP 处理的组件不需要 wasi:filesystem——宿主在实例化时可以干脆不注册文件系统接口(wasmtime serve 默认只注册 wasi:http/proxy world 的接口,见 §13.5),组件若导入了它就无法实例化;即使注册了,没有预打开目录也访问不到任何文件。这种"默认零权限 + 按需授权"的模型与容器"先给一个隔离环境、再按需收紧"的思路不同:前者的授权是宿主代码里一条条显式调用,后者依赖运维人员把安全策略配对。
本章引用的 WIT 均以 Wasmtime 44.0.0 仓库内的 crates/wasi/src/p2/wit/deps/*.wit 为准,包版本是 wasi:*@0.2.6(wasi:http 在 crates/wasi-http/wit/deps/http.wit)。
0.2.x 的小版本只做向后兼容的增补(新函数用 @since 标注、实验性接口用 @unstable 门控,例如 wasi:clocks/timezone),组件模型的链接器按 semver 把 @0.2.x 的导入匹配到宿主提供的其他 0.2 小版本(crates/wasmtime/src/runtime/component/linker.rs:27-50 的文档注释)。按 WebAssembly/WASI 仓库的 GitHub Releases(2026-09-24 核对):v0.2.0 是 2024 年 1 月 24 日投票通过的那一版(GitHub Release 页面 2024-04-24 才补发),之后 0.2.1(2024-08-01)、0.2.2(2024-10-03)、0.2.3(2024-12-05)、0.2.4(2025-02-06)、0.2.5(2025-04-03)、0.2.6(2025-06-12)、0.2.7(2025-08-12)、0.2.8(2025-10-07)、0.2.9(2025-12-02)、0.2.10(2026-02-03)、0.2.11(2026-04-07)、0.2.12(2026-06-02)大致按两个月一版发布;各版发布说明所列变更以文档澄清、仓库与流程调整、提案阶段变动为主,接口层面只有向后兼容的增补。Wasmtime 各版本内置的 WIT 版本见 §13.12.5。WASI Preview 2 定义了以下接口包:
| 包名 | 核心接口 | 能力 |
|---|---|---|
wasi:cli |
environment, exit, run, stdin/stdout/stderr |
命令行参数、环境变量、标准流、命令入口 |
wasi:filesystem |
types, preopens |
文件系统读写、目录遍历 |
wasi:io |
poll, streams |
异步 I/O 轮询和流读写 |
wasi:sockets |
tcp, udp, instance-network |
TCP/UDP 网络编程 |
wasi:http |
incoming-handler, outgoing-handler, types |
HTTP 服务器和客户端 |
wasi:clocks |
monotonic-clock, wall-clock |
高精度定时器和时钟 |
wasi:random |
random, insecure, insecure-seed |
密码学安全随机数(外加一组显式标记「不安全」的伪随机接口) |
下面逐个解析最重要的接口。
wasi:cli——命令行基础
下面几段 WIT 都是节选:省略了 use 语句、@since 标注、文档注释和部分函数。
package wasi:cli;
interface exit {
exit: func(status: result);
}
interface environment {
get-environment: func() -> list<tuple<string, string>>;
get-arguments: func() -> list<string>;
initial-cwd: func() -> option<string>;
}
interface stdin {
get-stdin: func() -> input-stream;
}
interface stdout {
get-stdout: func() -> output-stream;
}
interface stderr {
get-stderr: func() -> output-stream;
}
和 Preview 1 的 args_get/environ_get 相比,Preview 2 的类型签名更精确:get-environment 返回 list<tuple<string, string>>——键值对,而不是两个平行的指针数组。get-arguments 返回 list<string>——不需要调用者预分配缓冲区。
更深层的设计差异:Preview 1 的 args_get 要求调用者传入两个指针(argv 和 argv_buf),调用者需要先调用 args_sizes_get 获取参数数量和总长度,分配内存,再调用 args_get 填充。这是一个两步操作——如果参数在两次调用之间变化(理论上不可能,但 API 没有保证),就会出错。Preview 2 的 get-arguments 一步完成——WIT 的 list<string> 类型由 Canonical ABI 自动处理内存分配,调用者直接获得结果。
wasi:clocks 和 wasi:random
package wasi:clocks;
interface monotonic-clock {
type instant = u64;
type duration = u64;
now: func() -> instant;
resolution: func() -> duration;
subscribe-instant: func(when: instant) -> pollable;
subscribe-duration: func(when: duration) -> pollable;
}
interface wall-clock {
// 规范写明:seconds 是自 1970-01-01T00:00:00Z 起的秒数(Unix 时间,与时区无关)
record datetime { seconds: u64, nanoseconds: u32 }
now: func() -> datetime;
resolution: func() -> datetime;
}
package wasi:random;
interface random {
get-random-bytes: func(len: u64) -> list<u8>;
get-random-u64: func() -> u64;
}
// 不安全但快速的随机数是一个独立接口,不是 random 里的一个函数
interface insecure {
get-insecure-random-bytes: func(len: u64) -> list<u8>;
get-insecure-random-u64: func() -> u64;
}
subscribe-duration 是异步的关键——它返回一个 pollable,可以在 wasi:io/poll 中等待。这把"定时器"变成了"I/O 事件"——统一了定时器和文件/网络 I/O 的等待模型。
wasi:io——异步 I/O 的基础设施
package wasi:io;
interface poll {
resource pollable {
ready: func() -> bool;
block: func();
}
poll: func(in: list<borrow<pollable>>) -> list<u32>;
}
interface streams {
resource input-stream {
read: func(len: u64) -> result<list<u8>, stream-error>;
blocking-read: func(len: u64) -> result<list<u8>, stream-error>;
skip: func(len: u64) -> result<u64, stream-error>;
subscribe: func() -> pollable;
}
resource output-stream {
// 写之前先问"现在能写多少字节",这是 P2 输出流的背压入口
check-write: func() -> result<u64, stream-error>;
write: func(contents: list<u8>) -> result<_, stream-error>;
blocking-write-and-flush: func(contents: list<u8>) -> result<_, stream-error>;
flush: func() -> result<_, stream-error>;
blocking-flush: func() -> result<_, stream-error>;
subscribe: func() -> pollable;
splice: func(src: borrow<input-stream>, len: u64) -> result<u64, stream-error>;
}
}
subscribe 方法是异步的核心——每个 input-stream 和 output-stream 都可以返回一个 pollable,表示"这个流何时可读/可写"。poll::poll 接收一组 pollable,返回就绪的索引列表(列表为空会直接 trap)——语义接近 POSIX 的 poll():每次调用都传入完整的等待列表,而不是像 epoll 那样在内核里注册一次、之后只取就绪事件;好处是通过 WIT 接口跨语言可用。
这种设计和 POSIX poll() 的一个关键区别:POSIX 的 pollfd 包含 fd、events、revents 三个字段——调用者需要构造结构体数组,检查返回的 revents 位掩码。WASI 的 poll 接收 pollable 资源列表,返回就绪索引列表——不需要位掩码操作,更类型安全。代价是每次 poll 都要把整张列表传给宿主,Wasmtime 为列表里的每个 pollable 构造一个 future、再逐个轮询(crates/wasi-io/src/impls.rs:15-67),开销随列表长度线性增长——对于大规模并发(万级连接),这一点需要考虑。
read vs blocking-read 的区别:read 是非阻塞的——如果暂时没有数据,立即返回空列表(空列表不是 EOF,流关闭时返回的是 stream-error::closed)。blocking-read 是阻塞的——它会等待直到至少读到一个字节或出错。Wasmtime 宿主侧的实现是「等流就绪(ready().await)→ 调 read → 读到空就再等」的循环(crates/wasi-io/src/streams.rs:35);至于等待期间宿主线程是否被占住,取决于嵌入方式,见 §13.6。
wasi:http——HTTP 客户端和服务器
package wasi:http;
interface incoming-handler {
handle: func(request: incoming-request, response-out: response-outparam);
}
interface outgoing-handler {
// 注意返回的是 future-incoming-response,不是直接的 response
handle: func(request: outgoing-request, options: option<request-options>)
-> result<future-incoming-response, error-code>;
}
interface types {
resource incoming-request {
method: func() -> method;
path-with-query: func() -> option<string>;
scheme: func() -> option<scheme>;
authority: func() -> option<string>;
headers: func() -> headers;
// 拿到的是 incoming-body 资源,再从它取 input-stream
consume: func() -> result<incoming-body>;
}
resource outgoing-response {
constructor(headers: headers);
status-code: func() -> status-code;
set-status-code: func(status-code: status-code) -> result;
headers: func() -> headers;
body: func() -> result<outgoing-body>;
}
}
incoming-handler 是 Preview 2 最具变革性的接口——它定义了一个被动式 HTTP 处理器:宿主接收 HTTP 请求,构造 incoming-request 对象,调用模块的 handle 函数。模块不需要自己监听端口、接受连接——这些全部由宿主负责。
这和传统的 HTTP 服务器模型(主动绑定端口、循环 accept)完全不同——而和《Axum 设计与实现》专栏第 5 章的 Handler trait 设计异曲同工:都是"框架负责连接管理,用户只写请求处理逻辑"。
flowchart TD
subgraph "传统 HTTP 服务器 (Axum/Nginx)"
A[应用代码] --> B["bind + listen + accept<br/>(运行时管理)"]
B --> C["handler(request) → response<br/>(用户逻辑)"]
end
subgraph "WASI HTTP (wasi:http/incoming-handler)"
D["宿主 (Wasmtime)"] --> E["bind + listen + accept<br/>(hyper 服务器)"]
E --> F["handle(request, response-out)<br/>(WASM 模块逻辑)"]
F --> G["构造 outgoing-response"]
G --> E
end
style B fill:#f59e0b,color:#333
style E fill:#10b981,color:#333
style F fill:#6366f1,color:#fff
13.3 wasm32-wasip2 目标
Rust 1.78(2024-05-02)加入了 wasm32-wasip2 目标三元组(当时是 Tier 3)——专门为 WASI Preview 2 + 组件模型设计,1.82(2024-10-17)升为 Tier 2;pin 的 1.96.0 里,平台文档(src/doc/rustc/src/platform-support/wasm32-wasip2.md:21)写明该目标完整支持 std(原文 The target supports std fully),目标定义里它仍是 Tier 2、std: Some(true)(compiler/rustc_target/src/spec/targets/wasm32_wasip2.rs:66-68)。同一轮变更还做了一件事:1.78 起把含糊的 wasm32-wasi 改名为 wasm32-wasip1,让"哪个 Preview"写在名字里;wasm32-wasi 在 1.81 起使用即告警,1.84(2025-01-09)被移除(均见 Rust 仓库 RELEASES.md)。今天服务端 WASM 的两个活跃目标就是 wasm32-wasip1(Preview 1、裸模块)和 wasm32-wasip2(Preview 2、组件),浏览器侧仍是 wasm32-unknown-unknown。
# 安装目标
rustup target add wasm32-wasip2
# 编译(产物是组件格式的 .wasm)
cargo build --target wasm32-wasip2 --release
wasm32-wasip2 和 wasm32-wasip1 的关键差异:
| 维度 | wasm32-wasip1 |
wasm32-wasip2 |
|---|---|---|
| 产物格式 | 裸 .wasm 模块 |
组件 .wasm(含 WIT 元数据) |
| 系统调用风格 | fd 整数 + wasi_snapshot_preview1 |
类型化资源 + wasi:cli/filesystem/... |
std::net |
只有返回 Unsupported 的桩实现 |
可用(通过 wasi:sockets) |
std::fs |
可用 | 可用 |
| 等待 I/O | 只有 poll_oneoff(订阅 fd 读写与时钟) |
wasi:io/poll 统一等待各类 pollable |
| 稳定状态 | Tier 2,不再添加新能力 | Tier 2(1.82 起) |
| 运行时要求 | 任何 WASI Preview 1 运行时 | 支持组件模型的运行时(Wasmtime ≥ 17) |
wasm32-wasip2 编译产物的一个重要变化:它不是普通的 .wasm 模块——它是一个组件。rustc 在这个目标上用 wasm-component-ld 做链接器(compiler/rustc_target/src/spec/targets/wasm32_wasip2.rs:28),链接完直接把核心模块封装成组件:包含 WIT 类型信息、导入/导出的接口声明,以及把核心模块接到组件接口上的胶水实例。这意味着它不能在只支持 Preview 1 的运行时上执行——必须使用支持组件模型的运行时。
Rust 代码示例:命令行程序
use std::fs;
use std::io;
fn main() -> io::Result<()> {
// 和原生 Rust 代码完全一样——std::fs 和 std::io 在 wasip2 上工作
let content = fs::read_to_string("input.txt")?;
let word_count = content.split_whitespace().count();
println!("Words: {}", word_count);
fs::write("output.txt", format!("Word count: {}", word_count))?;
Ok(())
}
编译到 wasm32-wasip2 后,std 的文件和标准流走 wasi-libc(std::sys 在 wasi 上复用 unix 风格的 libc 调用路径),由 wasi-libc 转成 P2 调用;参数与随机数则由 std 直接调 WASI 绑定(library/std/src/sys/args/wasip2.rs、library/std/src/sys/random/wasip2.rs)。用 1.96.0 编译上面的程序、再用 wasm-tools print 看核心模块的导入,fs::read_to_string 落到 wasi:filesystem/types 的 descriptor.open-at + descriptor.read-via-stream + input-stream.blocking-read,println! 落到 wasi:cli/stdout 的 get-stdout + output-stream 的 check-write/write/blocking-flush(output-stream 上并没有叫 blocking-write 的方法)。Rust 代码不需要任何条件编译——标准库在 wasm32-wasip2 目标上自动使用 WASI Preview 2 的实现。运行时记得把工作目录授权给它:wasmtime run --dir . target/wasm32-wasip2/release/<名字>.wasm,不加 --dir 会报 NotFound(没有任何预打开目录)。
组件格式与 cargo-component
上面的命令行程序用普通的 cargo build --target wasm32-wasip2 就得到了可运行的组件,不需要额外工具。要导出自定义接口(自己写 WIT world)时才需要绑定生成工具。过去常用的是 cargo-component:
# 安装 cargo-component
cargo install cargo-component
# 创建新的组件项目(--lib 生成库组件;不加则生成命令)
cargo component new my-wasi-app --lib
# 构建
cargo component build --release
它做两件事:从 WIT 生成 Rust 绑定,并把编译结果封装成组件。按 0.21.1(2025-04-07 发布,此后未再发版,README 仍标注 experimental)的源码,有两点和直觉不同:它默认按 wasm32-wasip1 编译(src/lib.rs:200-208 在没指定 wasm 目标时自动加 --target wasm32-wasip1),再套上 Preview 1 适配器转成组件(适配器见 §13.7);生成的 Cargo.toml 依赖的是 wit-bindgen-rt,不是 wit-bindgen:
[dependencies]
wit-bindgen-rt = { version = "...", features = ["bitflags"] } # 由 cargo add 写入当时的最新版
[package.metadata.component]
package = "component:my-wasi-app"
[package.metadata.component.dependencies]
如今更直接的做法是 wasm32-wasip2 目标 + wit-bindgen(第 15 章):rustc 自带的 wasm-component-ld 负责封装组件,wit_bindgen::generate! 负责绑定,不再需要 cargo 子命令。
全栈编译的实际配置
很多项目需要同时编译浏览器版本和服务器版本——浏览器用 wasm32-unknown-unknown + wasm-bindgen,服务器用 wasm32-wasip2 + WASI。Cargo 的 cfg 条件编译可以实现:
[lib]
crate-type = ["cdylib", "lib"]
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { version = "0.2", optional = true }
web-sys = { version = "0.3", features = ["Window"], optional = true }
[features]
browser = ["wasm-bindgen", "web-sys"]
server = []
#[cfg(feature = "browser")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "browser")]
#[wasm_bindgen]
pub fn process(data: &[u8]) -> Vec<u8> {
core_process(data)
}
#[cfg(feature = "server")]
pub fn process(data: &[u8]) -> Vec<u8> {
core_process(data)
}
fn core_process(data: &[u8]) -> Vec<u8> {
// 共享的核心逻辑
data.iter().map(|b| b.wrapping_add(1)).collect()
}
关键点:cfg(target_arch = "wasm32") 在两个 WASM 目标上都匹配,要区分浏览器和 WASI 得看 target_os——wasm32-unknown-unknown 上是 "unknown"(compiler/rustc_target/src/spec/targets/wasm32_unknown_unknown.rs:17),WASI 目标上是 "wasi"。稳妥写法是 #[cfg(target_os = "wasi")] 走 WASI 分支、#[cfg(not(target_os = "wasi"))] 走浏览器分支;需要再区分 P1/P2 时加 target_env = "p1"/"p2"(平台文档推荐 #[cfg(all(target_os = "wasi", target_env = "p2"))])。
13.4 Wasmtime 44 的 WASI Preview 2 实现
Wasmtime 从版本 17(2024-01-25)开始默认支持 WASI 0.2.0,到版本 44(RELEASES.md 开头写的发布日期是 2026-04-20)内置的是 0.2.6 的 WIT。其 WASI Preview 2 的实现架构:
flowchart TD
A[WASM 组件] --> B[组件模型运行时]
B --> C{WASI 接口调用}
C -->|wasi:cli| D[CLI 实现<br/>参数/环境/退出]
C -->|wasi:filesystem| E[文件系统实现<br/>cap-std 沙箱]
C -->|wasi:sockets| F[Socket 实现<br/>tokio 异步 I/O]
C -->|wasi:http| G[HTTP 实现<br/>hyper 处理]
C -->|wasi:clocks| H[时钟实现<br/>系统时间]
C -->|wasi:random| I[随机数实现<br/>系统 RNG]
C -->|wasi:io/poll| J[Poll 实现<br/>tokio 事件循环]
E --> K[操作系统]
F --> K
G --> K
H --> K
I --> K
J --> K
style B fill:#6366f1,color:#fff
style E fill:#f59e0b,color:#333
style J fill:#10b981,color:#333
组件实例化流程
Preview 2 的模块不是裸 .wasm——它是组件(Component)。Wasmtime 必须用组件模型的方式实例化:
flowchart TD
A[".wasm 组件<br/>(含 WIT 元数据)"] --> B[解码组件二进制]
B --> C[验证接口类型]
C --> D[解析导入需求<br/>wasi:cli / filesystem / ...]
D --> E[创建 WASI 上下文<br/>WasiCtxBuilder]
E --> F[Linker 注册 WASI 实现]
F --> G[实例化组件]
G --> H{有 start 函数?}
H -->|是| I[执行 start]
H -->|否| J[等待导出函数调用]
style E fill:#f59e0b,color:#333
style F fill:#10b981,color:#333
style G fill:#6366f1,color:#fff
WasiCtxBuilder
Wasmtime 用 WasiCtxBuilder 构建 WASI 上下文——控制模块获得哪些能力:
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Engine, Result, Store};
use wasmtime_wasi::p2::bindings::sync::Command;
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtx, WasiCtxView, WasiView};
// Store 里的 T 必须能交出 WasiCtx 和资源表
struct MyState {
ctx: WasiCtx,
table: ResourceTable,
}
impl WasiView for MyState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
}
}
fn run_component() -> Result<()> {
let engine = Engine::default();
let component = Component::from_file(&engine, "my_app.wasm")?;
let mut linker = Linker::<MyState>::new(&engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
let mut builder = WasiCtx::builder();
builder
.args(&["my-app", "--verbose"])
.env("MODE", "production")
.preopened_dir("./data", "data", DirPerms::READ, FilePerms::READ)?
.inherit_stdout()
.inherit_stderr();
let mut store = Store::new(
&engine,
MyState { ctx: builder.build(), table: ResourceTable::new() },
);
// Command 是 `wasi:cli/command` world 的生成绑定,导出 wasi:cli/run
let command = Command::instantiate(&mut store, &component, &linker)?;
match command.wasi_cli_run().call_run(&mut store)? {
Ok(()) => Ok(()),
Err(()) => std::process::exit(1),
}
}
add_to_linker_sync(crates/wasi/src/p2/mod.rs:452,WasiView / WasiCtxView 在 crates/wasi/src/view.rs:36、:44)注册的正是 wasi:cli/imports world 的全部接口——wasi:cli/environment、wasi:cli/exit、wasi:filesystem/types、wasi:clocks/*、wasi:random/*、wasi:sockets/* 以及来自 wasmtime-wasi-io 的 wasi:io/{error,poll,streams}。组件的每一个 WASI 调用都经过这些注册的实现——未注册的接口在实例化时就会报错。异步嵌入换成 add_to_linker_async + p2::bindings::Command(非 sync 子模块)。注意 wasi:http 不在这个 crate 里,它在 wasmtime-wasi-http。
WasiCtxBuilder 的安全边界
WasiCtxBuilder 的每个方法都对应一个能力授权——不调用的方法意味着不授权。这是能力安全的核心实现:
// 最小权限:只允许读取标准输入、写入标准输出
let wasi = WasiCtxBuilder::new()
.inherit_stdin() // 授予 stdin 读取
.inherit_stdout() // 授予 stdout 写入
// 没有 inherit_stderr —— 模块写 stderr 会落到默认的 null 流
// 没有 preopened_dir —— 模块无法访问文件系统
// 没有 env —— 模块无法读取环境变量
.build();
这种"默认零权限"的设计避免了 Docker 的一个常见安全失误——Dockerfile 经常 RUN chmod 777 /data 或挂载整个主机文件系统,因为配置正确的细粒度权限太繁琐。WASI 的能力安全在架构层面防止了这种"图方便"导致的安全漏洞——你无法授予你没有的能力。
WasiCtxBuilder 和 cap-std 的配合实现文件系统沙箱——preopened_dir(host_path, guest_path, dir_perms, file_perms) 在内部用 cap_std::fs::Dir::open_ambient_dir 打开宿主目录(crates/wasi/src/ctx.rs:298),得到的 Dir 句柄本身就是受限的——它只能访问创建时指定目录及其子目录。即使在 WASM 模块内部调用 std::fs::canonicalize("../../etc/passwd"),越界也会返回 PermissionDenied——但拦截不是靠「先规范化再比较前缀」(那种写法有 TOCTOU 窗口),而是靠让解析动作本身带上边界约束,细节见第 12 章 §12.6。
实例生命周期
一个组件实例从创建到销毁的完整生命周期:
stateDiagram-v2
[*] --> Creating: 加载组件文件
Creating --> Configuring: 构建 WasiCtx
Configuring --> Linking: 添加 host 函数
Linking --> Instantiating: 实例化组件
Instantiating --> Ready: 实例化完成
Ready --> Running: 调用导出函数
Running --> Waiting: poll 让出执行权
Waiting --> Running: I/O 就绪 恢复执行
Running --> Ready: 函数返回
Ready --> Destroyed: Store drop
Running --> Trapped: 运行时错误
Trapped --> Destroyed: 清理
这几个阶段的开销极不均衡:Creating(把组件编译成机器码)压倒性地占大头,Configuring + Linking 次之,Instantiating 最小。所以优化冷启动的第一件事永远是把编译挪走——构建期用 Component::serialize 产出 cwasm,运行时改用 Component::deserialize 加载。具体比例随模块大小和配置变化,要数字自己量。
13.5 HTTP 处理器:wasi:http/incoming-handler
wasi:http 是 Preview 2 新增的最重要的接口——它让 WASM 模块可以直接处理 HTTP 请求/响应,不需要外部代理。
WASI HTTP 的设计哲学可以概括为一个类比:如果 Axum 是"你自己开店、自己接待客人",那 WASI HTTP 就是"你只负责做菜,餐厅负责接待和端菜"。模块不需要关心 TCP 连接管理、HTTP 协议解析、keep-alive——它只需要实现"收到请求 → 返回响应"的逻辑。
实现一个 HTTP Handler
下面用 wasi crate(0.14.7,Cargo 会拉取它所依赖的 wasip2 1.0.x 生成绑定)写一个处理器,crate-type = ["cdylib"],cargo build --target wasm32-wasip2 --release 编出组件:
use wasi::exports::http::incoming_handler;
use wasi::http::types::*;
struct HttpHandler;
impl incoming_handler::Guest for HttpHandler {
fn handle(request: IncomingRequest, response_out: ResponseOutparam) {
let path = request.path_with_query().unwrap_or("/".to_string());
// Method 是 WIT variant 生成的枚举,没有实现 Display,直接模式匹配
match (request.method(), path.as_str()) {
(Method::Get, "/") => {
let body = "Hello from WASM!";
send_response(response_out, 200, body.as_bytes());
}
(Method::Get, "/api/status") => {
let body = r#"{"status":"ok","runtime":"wasi-preview2"}"#;
send_response(response_out, 200, body.as_bytes());
}
_ => {
send_response(response_out, 404, b"Not Found");
}
}
}
}
fn send_response(response_out: ResponseOutparam, status: u16, body: &[u8]) {
let response = OutgoingResponse::new(Fields::new());
response.set_status_code(status).unwrap();
let outgoing_body = response.body().unwrap();
// 先把响应头交出去,再写 body
ResponseOutparam::set(response_out, Ok(response));
{
let stream = outgoing_body.write().unwrap();
// 单次 blocking-write-and-flush 最多 4096 字节,更长的 body 要分块写
stream.blocking_write_and_flush(body).unwrap();
} // 子资源 output-stream 必须先 drop,才能 finish 父资源 outgoing-body
OutgoingBody::finish(outgoing_body, None).unwrap();
}
wasi::http::proxy::export!(HttpHandler);
几处容易写错的地方(都在 1.96.0 上实编实跑过):导出用的 Guest trait 在 wasi::exports::http::incoming_handler,而不是导入侧的 wasi::http::incoming_handler;导出宏是 wasi::http::proxy::export!;Method 没有实现 Display,不能 .to_string();最关键的是 OutgoingBody::finish——只写 body 不调 finish 就返回,wasmtime serve 端会报 hyper::Error(User(Body), ErrorCode::HttpProtocolError),curl 收到空响应。
HTTP 请求的完整流转
sequenceDiagram
participant C as 客户端
participant H as hyper 服务器<br/>(Wasmtime 宿主)
participant W as WASM 组件
participant F as 文件系统<br/>(wasi:filesystem)
C->>H: GET /api/status HTTP/1.1
H->>H: 构造 incoming-request
H->>W: handle(request, response-out)
W->>W: 解析 path = "/api/status"
W->>W: 构造 JSON 响应体
W->>W: 调用 outgoing-response.body()
W-->>H: ResponseOutparam::set(response)
W->>W: blocking-write-and-flush(body)
W->>W: OutgoingBody::finish
H->>H: 读取 outgoing-response 与 body
H-->>C: HTTP/1.1 200 OK<br/>{"status":"ok"}
Note over C,F: 同一个 WASM 组件可以同时导入 wasi:filesystem
C->>H: GET /files/data.json
H->>W: handle(request, response-out)
W->>F: read("data.json")
F-->>W: file content
W->>W: 构造响应
W-->>H: ResponseOutparam::set(response)
H-->>C: 200 OK + file content
wasmtime serve:一行命令启动 HTTP 服务
Wasmtime 提供了 wasmtime serve 子命令,一行命令即可把 WASM 组件变成 HTTP 服务器:
# 不加参数就监听默认的 0.0.0.0:8080
# (src/commands/serve.rs:102 的 DEFAULT_ADDR = 0.0.0.0:8080,
# src/commands/serve.rs:118 的 #[arg(long, default_value_t = DEFAULT_ADDR)])
wasmtime serve my_handler.wasm
# 显式指定地址(注意参数名是 --addr,不是 --listen)
wasmtime serve --addr 0.0.0.0:8080 my_handler.wasm
# 用 Rust std 编出的处理器一般要加 -Scli(原因见下文)
wasmtime serve -Scli --addr 0.0.0.0:8080 my_handler.wasm
# 指定允许的目录、环境变量(同样要 -Scli 才会注册文件系统 / 环境变量接口)
wasmtime serve -Scli --addr 0.0.0.0:8080 --dir ./data --env DB_PATH=/data/db my_handler.wasm
-Scli 这个开关值得单独说:wasmtime serve 默认只注册 wasi:http/proxy world 用到的接口(wasmtime_wasi_http::p2::add_to_linker_async,其中 CLI 部分只有标准流),加 -Scli 才注册完整的 wasi:cli 命令接口(含文件系统、环境变量)再补上 HTTP(src/commands/serve.rs:461-489)。而 Rust 的 std 在 wasm32-wasip2 上会让组件导入 wasi:cli/environment、wasi:cli/exit 等接口——上一节的处理器实测就导入了它们,不加 -Scli 启动时直接报 component imports instance `wasi:cli/environment@0.2.12`, but a matching implementation was not found in the linker。
wasmtime serve 做了这些事:
- 实例化 WASM 组件,配置
wasi:http导入(加-Scli时再加完整的wasi:cli导入) - 启动一个 hyper HTTP 服务器,监听指定端口
- 收到请求时,构造
incoming-request资源,调用 WASM 的handle函数 - WASM 处理完成后,把
outgoing-response写回 hyper 的响应流
底层 hyper 服务器处理了 TCP 连接管理、HTTP/1.1 协议解析、keep-alive、分块传输编码等细节——WASM 模块只需要实现业务逻辑。
HTTP Handler 的宿主侧配置
在宿主侧,需要用 wasmtime_wasi_http(crate 名 wasmtime-wasi-http)配置 HTTP 支持。它的 add_to_linker_async(crates/wasi-http/src/p2/mod.rs:454)注册的是整个 wasi:http/proxy world——内部先调 wasmtime_wasi::p2::add_to_linker_proxy_interfaces_async 注册 wasi:io/clocks/random/标准流,再注册 wasi:http;只要 HTTP 那部分用 add_only_http_to_linker_async(:467)。所以它不能和 wasmtime_wasi::p2::add_to_linker_async(crates/wasi/src/p2/mod.rs:313)叠加使用——两者重复注册 wasi:io 等接口,Linker 会报 map entry `wasi:io/error@0.2.6` defined twice(实测)。组件若还导入了完整的 wasi:cli(Rust std 编出的组件通常如此),就要换一种组合。
下面是本专栏自己写的嵌入代码,已用 wasmtime / wasmtime-wasi / wasmtime-wasi-http 44.0.0 编译,并实际实例化了上一节的处理器:
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Engine, Result, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::{WasiHttpCtx, p2::{WasiHttpCtxView, WasiHttpView}};
struct MyState { ctx: WasiCtx, http_ctx: WasiHttpCtx, table: ResourceTable }
impl WasiView for MyState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
}
}
impl WasiHttpView for MyState {
fn http(&mut self) -> WasiHttpCtxView<'_> {
WasiHttpCtxView { ctx: &mut self.http_ctx, table: &mut self.table, hooks: Default::default() }
}
}
async fn run_http_component() -> Result<()> {
let engine = Engine::default();
let component = Component::from_file(&engine, "http_handler.wasm")?;
let mut linker = Linker::<MyState>::new(&engine);
// 完整的 wasi:cli 命令接口 + 只补 HTTP;两个 add_to_linker_async 不能叠加
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
let state = MyState {
ctx: WasiCtx::builder().inherit_stdio().build(),
http_ctx: WasiHttpCtx::new(),
table: ResourceTable::new(),
};
let mut store = Store::new(&engine, state);
let _instance = linker.instantiate_async(&mut store, &component).await?;
// 组件导出的是 wasi:http/incoming-handler,用生成绑定(Proxy world)来调,
// 而不是 get_typed_func —— handle 的参数是资源句柄,不是裸 i32
Ok(())
}
出站方向要分两条路看。组件直接用 wasi:sockets(包括 Rust 的 std::net)建连接时,走的是 WasiCtxBuilder 上的网络授权——inherit_network()(放开全部地址)、allow_tcp / allow_udp / allow_ip_name_lookup 的开关,以及 socket_addr_check 这个逐地址回调:
let mut builder = WasiCtx::builder();
builder.socket_addr_check(|addr, _use| {
Box::pin(async move { matches!(addr.port(), 80 | 443) })
});
但组件通过 wasi:http/outgoing-handler 发请求时不经过这层检查:宿主把请求交给 WasiHttpHooks::send_request(crates/wasi-http/src/p2/http_impl.rs:93),默认实现 default_send_request 直接用 tokio 的 TcpStream::connect 连目标(crates/wasi-http/src/p2/mod.rs:555、:595),socket_addr_check 管不到它。要限制 HTTP 出站,得自己实现 hooks,再把它放进 WasiHttpCtxView 的 hooks 字段。
下面是一个白名单写法(本专栏自己写的示例,已用 wasmtime-wasi-http 44.0.0 编译通过):
use wasmtime_wasi_http::p2::bindings::http::types::ErrorCode;
use wasmtime_wasi_http::p2::body::HyperOutgoingBody;
use wasmtime_wasi_http::p2::types::{HostFutureIncomingResponse, OutgoingRequestConfig};
use wasmtime_wasi_http::p2::{HttpResult, WasiHttpHooks, default_send_request};
struct AllowList;
impl WasiHttpHooks for AllowList {
fn send_request(
&mut self,
request: hyper::Request<HyperOutgoingBody>,
config: OutgoingRequestConfig,
) -> HttpResult<HostFutureIncomingResponse> {
match request.uri().host() {
Some("api.example.com") => Ok(default_send_request(request, config)),
_ => Err(ErrorCode::HttpRequestDenied.into()),
}
}
}
这又是一个能力安全的实例:宿主在这两个入口上不放行,WASM 组件就不能像 Node.js 代码那样随意 fetch("http://evil.com/exfil")。
与 Axum Handler 的对比
WASI HTTP Handler 和 Axum 的 Handler trait 有相似的设计哲学,但实现层面差异显著:
// Axum Handler —— 直接访问 Rust 的全部生态
async fn handle_axum(req: Request) -> Response {
let body = req.into_body();
let bytes = axum::body::to_bytes(body, 1024).await.unwrap();
// 可以直接用 tokio::fs、sqlx、reqwest...
let db_result = sqlx::query("SELECT ...").fetch_one(&pool).await;
Response::builder().body("ok".into()).unwrap()
}
// WASI HTTP Handler —— 只能通过 WASI 接口访问外部世界
fn handle_wasi(request: IncomingRequest, response_out: ResponseOutparam) {
let path = request.path_with_query().unwrap_or("/".to_string());
// 没有 tokio::fs —— 只能用 wasi:filesystem
// 没有 sqlx —— 只能通过 wasi:sockets 自行实现数据库协议
// 没有 reqwest —— 只能用 wasi:http/outgoing-handler
send_response(response_out, 200, b"ok");
}
核心区别:Axum Handler 运行在完整的 Rust 异步运行时上,可以自由使用任何 crate;WASI Handler 运行在沙箱中,所有外部交互必须通过 WASI 接口。前者的优势是灵活性和生态,后者的优势是安全性和可移植性——同一个 WASI Handler 可以在任何实现了 wasi:http/proxy world 的宿主上执行,不需要重新编译(前提是宿主支持组件模型——WAMR 这类只跑核心模块的运行时不在此列)。
13.6 异步 I/O:wasi:io/poll 的实现
Preview 2 的异步 I/O 是最复杂的子系统。WASI 0.2 的接口层面没有 async/await(组件模型的原生异步随 WASI 0.3 在 2026 年 6 月才定稿,Wasmtime 44 里仍默认关闭,见 §13.14),所以异步通过 pollable + 轮询的协作模型实现。
poll 的工作机制
// wasi:io/poll.wit
poll: func(in: list<borrow<pollable>>) -> list<u32>;
poll 接收一组 pollable 对象,阻塞直到至少一个就绪,返回就绪对象的索引列表。它的语义接近 POSIX 的 poll()——但实现路径完全不同。
sequenceDiagram participant W as WASM 模块 participant R as Wasmtime 运行时 participant T as tokio 运行时 participant O as 操作系统 W->>R: poll::poll([stream, timer]) R->>T: 为每个 pollable 建 future,逐个 poll(PollList) T->>O: epoll_wait / kqueue Note over O: 等待 I/O 事件... O-->>T: stream 就绪 T-->>R: stream.ready() 返回 R-->>W: 返回 [0](stream 就绪) W->>R: stream.read(4096) R->>T: AsyncRead::read() T->>O: read(fd, buf) O-->>T: 数据返回 T-->>R: 数据拷贝到 WASM 线性内存 R-->>W: 返回 list<u8>(读到的字节)
关键设计:WASM 模块不知道 tokio 的存在——它只调用 WASI 的 poll 接口,Wasmtime 负责把 poll 映射到 tokio 的异步 I/O。这种分层让模块代码不依赖具体的异步运行时——宿主换一套事件循环,模块不用改。
Rust 中的异步 WASI 编程
在 wasm32-wasip2 上,Rust 的 async fn 照样编译为状态机,但 WASI 0.2 不提供执行器——guest 里要么自己带一个基于 poll 的小执行器,要么像下面这样不用 async,直接拿 pollable 等待(用 wasi 0.14.7 在 1.96.0 上实编实跑过):
use wasi::clocks::monotonic_clock;
use wasi::io::poll;
use wasi::io::streams::InputStream;
fn read_with_timeout(stream: &InputStream, timeout_ms: u64) -> Option<Vec<u8>> {
let stream_pollable = stream.subscribe();
let timer_pollable = monotonic_clock::subscribe_duration(
timeout_ms * 1_000_000, // 纳秒
);
// poll 接收的是 borrow<pollable> 列表,Rust 绑定里是 &[&Pollable]
let ready = poll::poll(&[&stream_pollable, &timer_pollable]);
if ready.contains(&0) {
// stream 就绪——读取数据(可能读到 0 字节,也可能是 Closed)
stream.read(4096).ok()
} else {
None // 超时
}
}
Wasmtime 的 call_async 方法实现协作调度:
// Wasmtime 宿主代码(组件;核心模块用 wasmtime::Instance::get_typed_func,结果类型写 i32)
let result = instance
.get_typed_func::<(i32,), (i32,)>(&mut store, "process")?
.call_async(&mut store, (input,))
.await?;
call_async 让 WASM 函数在 tokio 的异步上下文中执行(WASM 跑在独立的 fiber 栈上)。在异步嵌入(add_to_linker_async)下,当 WASM 调用 poll::poll 时,Wasmtime 挂起 WASM 执行,把控制权交回 tokio——tokio 可以调度其他任务或等待 I/O。I/O 就绪后,tokio 恢复 Wasmtime,Wasmtime 恢复 WASM 执行。
异步流的读写模式
WASI 的流定义了两种读写模式:非阻塞(read/write)和阻塞(blocking-read/blocking-write-and-flush)。
非阻塞模式适用于 poll 循环:
// 非阻塞模式:手动 poll 循环(use wasi::io::streams::StreamError)
let pollable = stream.subscribe();
loop {
pollable.block(); // 等价于只含这一个 pollable 的 poll::poll
match stream.read(4096) {
Ok(data) => process(data), // 可能为空:只是暂时没数据,不是 EOF
Err(StreamError::Closed) => break, // 这才是 EOF
Err(StreamError::LastOperationFailed(e)) => {
eprintln!("read failed: {}", e.to_debug_string());
break;
}
}
}
drop(pollable); // 子资源 pollable 要先于 stream 释放
空列表不是 EOF——流关闭时 read 返回的是 Err(StreamError::Closed)。把空列表当 EOF,遇到数据分几次到达的管道或 socket 就会提前退出。
阻塞模式适用于简单的顺序读写——底层仍然是协作式调度:
// 阻塞模式:简单但高效
let data = stream.blocking_read(4096)?; // 让出执行权直到数据就绪
output.blocking_write_and_flush(&data)?; // 写 + flush 是同一个方法
// 注意:output-stream 上并没有单独的 blocking-write;
// 非阻塞的 write 之前必须先 check-write 问清楚"现在能写多少字节"
blocking-read / blocking-write-and-flush 会不会真的占住宿主线程,取决于嵌入方式。异步嵌入(add_to_linker_async + call_async)下,它是"让出执行权+等待恢复"的协作式阻塞:Wasmtime 挂起 WASM 的 fiber,在数据就绪后恢复,tokio 的工作线程不会阻塞。同步嵌入(add_to_linker_sync,§13.4 的例子)下,同步绑定调用 in_tokio(crates/wasi/src/p2/host/io.rs:113-115),而 in_tokio 内部就是 block_on(crates/wasi/src/runtime.rs:104-108),调用线程会一直等到 I/O 完成。
13.7 从 Preview 1 到 Preview 2 的迁移
| 维度 | Preview 1 | Preview 2 |
|---|---|---|
| API 风格 | C fd-based | WIT 接口定义 |
| 类型安全 | fd 是整数 | 资源类型 |
| 等待 I/O | 只有 poll_oneoff |
wasi:io/poll |
| 网络 | 只能用宿主预先传入的 socket(sock_accept/sock_recv/sock_send) |
wasi:sockets + wasi:http |
| 模块化 | 单体 monolith | 独立接口包 |
| 目标三元组 | wasm32-wasip1 |
wasm32-wasip2 |
| Rust std 支持 | 部分(std::net 只有桩实现) |
完整(含 std::net) |
| 组件模型 | 不使用 | 强制(输出组件格式) |
| Wasmtime 最低版本 | 很早的版本即支持 | 17.0+ |
迁移路径:wasm32-wasip1 只是稳定不再演进,新项目应使用 wasm32-wasip2。只用标准库的代码,迁移通常就是把构建目标换成 --target wasm32-wasip2;如果直接依赖了 wasi crate 的底层 API,再换依赖:
# 旧 (Preview 1)
[dependencies]
wasi = "0.11" # Preview 1 绑定(0.11.x+wasi-snapshot-preview1)
# 新 (Preview 2)
[dependencies]
wasi = "0.14" # Preview 2 绑定;0.14.5 起实现转到 wasip2 crate,wasi 只做转发
std::fs、std::time、std::env 这些标准库 API 在两个目标上都能工作——差异在底层实现而非用户 API;std::net 则只有在 wasm32-wasip2 上才真正可用(§13.3 的表)。另有两个需要注意的破坏性变化:
fd 不再是 i32:如果代码直接使用了 wasi::fd_read、wasi::fd_write 等 Preview 1 的底层 API(不通过 std::fs),这些 API 在 Preview 2 中不存在。需要改用 wasi::io::streams 的 input-stream/output-stream。
preopens 的路径映射:Preview 1 用 fd_prestat_dir_name 获取路径名;Preview 2 用 wasi:filesystem/preopens 接口返回 list<tuple<descriptor, string>>——类型更安全,但获取方式不同。
Preview 1 适配器:不重新编译也能变成组件
手里只有 P1 产物(或者某个工具链暂时只能出 P1 模块)时,还有一条路:用 Preview 1 适配器把它包成 P2 组件。适配器本身是一个 WASM 模块 wasi_snapshot_preview1.wasm,源码在 Wasmtime 仓库的 crates/wasi-preview1-component-adapter/,它导出全套 wasi_snapshot_preview1 函数、内部改调 P2 接口。它有三种构建变体(Cargo.toml 的 reactor(默认)/command/proxy 三个 feature):command 版额外导出 wasi:cli/run,适配带 _start 的命令程序;reactor 版只做函数层面的转接,适配库式模块;Wasmtime 的 GitHub Release 附带这三个变体的预编译产物,名字就是 wasi_snapshot_preview1.command.wasm / wasi_snapshot_preview1.reactor.wasm / wasi_snapshot_preview1.proxy.wasm(v44.0.0 的附件列表,2026-09-24 核对)。
用 pin 的 Wasmtime 44 源码自己构建 command 版,再把 §13.3 那个统计单词的程序按 wasm32-wasip1 编译后套上去:
# 在 wasmtime 仓库里构建适配器(command 版)
cargo build -p wasi-preview1-component-adapter --target wasm32-unknown-unknown \
--release --no-default-features --features command
cp target/wasm32-unknown-unknown/release/wasi_snapshot_preview1.wasm \
wasi_snapshot_preview1.command.wasm
# P1 模块 + 适配器 → P2 组件
wasm-tools component new target/wasm32-wasip1/release/wc.wasm \
--adapt wasi_snapshot_preview1=wasi_snapshot_preview1.command.wasm -o wc-component.wasm
wasm-tools component wit wc-component.wasm # 导入 wasi:cli/…@0.2.6 等,导出 wasi:cli/run@0.2.6
wasmtime run --dir . wc-component.wasm # 输出与原生 P1 模块相同
cargo-component 默认走的就是这条「P1 编译 + 适配器」的路子(§13.3)。反过来,Rust 1.96.0 的 wasm32-wasip2 产物不含适配器:用 wasm-tools print 看,组件里只有主模块和两个用于间接调用的小 shim/fixup 模块,主模块直接导入 wasi:filesystem/types@0.2.0、wasi:io/streams@0.2.0 等 P2 函数,组件层面的导入则是 @0.2.6(靠 §13.2 说的 semver 匹配对上)。
13.8 安全边界与运行时限制
文件系统沙箱
cap-std 的沙箱策略(第 12 章 §12.6 已经展开,这里只列要点):
- 相对句柄解析:所有路径都相对预打开目录那个 fd 解析,模块拿不到绝对路径
- 解析期约束:Linux 5.6+ 用
openat2的RESOLVE_BENEATH、FreeBSD 13.0+ 用openat(O_RESOLVE_BENEATH),由内核在一次系统调用里保证不越界;其余平台逐段打开路径分量,专门处理..和符号链接。越界返回PermissionDenied - 权限检查:即使路径合法,也要检查操作是否符合预打开时声明的权限——
DirPerms只有READ/MUTATE,FilePerms只有READ/WRITE(crates/wasi/src/filesystem.rs:72的FilePerms与:92的DirPerms两组bitflags!)
⚠️ 流传很广的一种描述是「cap-std 先 canonicalize 出绝对路径,再比较是不是以预打开目录为前缀」——这是错的,而且正是 cap-std 刻意避开的写法:检查与真正 open 之间存在 TOCTOU 窗口,路径中间某一段可以在两步之间被换成符号链接。cap-std 把边界约束放进解析动作本身,不存在「先算出宿主真实路径、再决定拒不拒绝」的中间状态。
网络沙箱
wasi:sockets 的安全模型和文件系统类似——模块默认没有网络能力。Wasmtime 通过配置控制:
# 组件(Preview 2)的网络授权走 -S 开关
wasmtime -S inherit-network my_component.wasm # 放开全部地址(TCP、UDP)
wasmtime -S inherit-network -S udp=n my_component.wasm # 放开地址,但禁掉 UDP
wasmtime -S allow-ip-name-lookup my_component.wasm # 单独放开 DNS 解析
wasmtime -S http my_component.wasm # 启用 wasi:http
# 注意 -S tcplisten / -S listenfd 只对 WASIp1 的裸模块有效,
# 组件路径上直接 bail:src/common.rs:336-341
嵌入 API 一侧对应的是 WasiCtxBuilder 上的 inherit_network / allow_tcp / allow_udp / allow_ip_name_lookup / socket_addr_check。默认值要看清(WasiCtxBuilder::new 的文档注释,以及 crates/wasi/src/sockets/mod.rs:91-99、:175-178 的两个 Default 实现):TCP/UDP 开关默认是开的,但地址检查默认拒绝一切地址;DNS 解析默认关闭。所以 -S tcp=y 本身放不开任何连接,必须配合 -S inherit-network(或嵌入时的 socket_addr_check)。用 1.96.0 编一个 std::net 程序实测:什么都不加时 TcpStream::connect 和 UdpSocket::bind 都返回 PermissionDenied,DNS 查询失败;-S tcp=y -S udp=n 仍是 PermissionDenied;-S inherit-network 后 TCP 收发成功,再加 -S udp=n 只有 UDP 被拒;DNS 要单独 -S allow-ip-name-lookup。
执行超时
执行超时是服务器场景的关键安全措施——防止恶意或有 bug 的模块无限循环占用 CPU。Wasmtime 用 epoch 机制实现:设置一个 epoch 计数器,模块执行到特定位置时检查当前 epoch 是否已过期——如果过期,trap 退出。
// 宿主代码:设置 5 秒超时
// 前提是 Engine 创建时开了 Config::epoch_interruption(true)
store.set_epoch_deadline(1);
let engine = engine.clone(); // Engine 是引用计数句柄,克隆一份给计时线程
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(5));
engine.increment_epoch();
});
// 超时后 WASM 的执行会被 trap 中断,宿主的调用返回 Err(Trap::Interrupt);
// WASM 模块自己捕获不到 trap,清理工作要由宿主做
资源限制
| 限制 | 默认 | 配置方式 |
|---|---|---|
| 线性内存最大 | 只受索引类型和宿主内存限制 | StoreLimitsBuilder::memory_size()(crates/wasmtime/src/runtime/limits.rs:238)+ Store::limiter() |
| 表最大元素数 | 同上 | StoreLimitsBuilder::table_elements()(同文件 250 行) |
| 实例最大数量 | 同上 | StoreLimitsBuilder::instances()(同文件 260 行) |
每次 memory.grow 是否放行 |
全放行 | 自行实现 ResourceLimiter::memory_growing(见第 11 章 §11.14.3) |
| 执行超时 | 无限制 | Config::epoch_interruption(true)(crates/wasmtime/src/config.rs:726)+ Store::set_epoch_deadline()(crates/wasmtime/src/runtime/store.rs:1113);配合 Engine::increment_epoch()(crates/wasmtime/src/engine.rs:849) |
| 指令预算 | 无限制 | Config::consume_fuel(true)(crates/wasmtime/src/config.rs:605)+ Store::set_fuel()(crates/wasmtime/src/runtime/store.rs:1049) |
| 文件大小 / 网络连接数 | 无限制 | 宿主操作系统限制 |
13.9 跨专栏关联:WASI 异步与 Tokio 异步
WASI Preview 2 的异步模型和《Tokio 源码深度解析》专栏第 8 章(I/O Driver)形成有趣对照:
- Tokio:Rust 代码直接调用
epoll/kqueue,自己管理 I/O 事件循环——运行时即调度器 - WASI:Rust 代码调用
wasi:io/poll,Wasmtime 把 poll 映射到宿主的异步 I/O——运行时是代理
两者都是"协作式异步"——任务在等待 I/O 时主动让出执行权。但 Tokio 是自包含的运行时(自己调度自己),WASI 是嵌入式运行时(宿主调度 WASM)。这导致一个架构差异:Tokio 可以做 work-stealing(线程间任务窃取),WASI 不能——WASM 模块没有跨线程共享状态的能力(除非用 threads 提案的共享内存;而 WASI Preview 2 没有创建线程的接口,wasi-threads 只有 wasm32-wasip1-threads 这条 P1 路线,提案本身仍在 Phase 1)。
从应用开发者的视角看,这种差异体现为一个选择:如果需要高性能服务器(高并发、低延迟、多核利用),用 Tokio 原生编译(x86_64/aarch64);如果需要安全隔离、可移植、快速启动,用 WASI Preview 2 编译(wasm32-wasip2)。两者不是竞争关系——而是"裸机性能"和"沙箱安全"之间的工程权衡。
13.10 WASI Preview 2 的实际部署场景
WASI Preview 2 的新能力(HTTP 处理、异步 I/O、类型化资源)在以下场景中已经开始实际使用。
边缘计算
边缘计算节点资源有限,对启动时间极其敏感。传统容器方案(Docker + OCI runtime)的冷启动要走完创建 namespace、挂载 rootfs、拉起进程这一整套;WASI 组件把编译挪到构建期(Component::serialize 产出 cwasm、运行时 Component::deserialize),再用 InstancePre 预先完成链接,每个请求只剩「建 Store + 实例化」。本机实测(Xeon Gold 6240,Wasmtime 44 嵌入 API,§13.3 那个 120KB 的命令组件,add_to_linker_sync + instantiate_pre,2000 次取中位数):从文件编译组件 17–44ms,而每次「建 Store + 实例化」约 12µs——差着三个数量级,冷启动的大头完全在编译。具体数值随模块大小和配置浮动,上线前必须在自己的目标机型上量。
边缘计算的典型工作流:CDN 节点收到 HTTP 请求 → 路由到对应的 WASI 组件 → 组件处理请求并返回响应。整个流程中,组件可以按需加载和卸载——不用的组件不占用内存。Wasmtime 的实例化开销足够低,支持"每请求一个实例"的隔离模型——不同用户的请求在独立的组件实例中执行,互不干扰。
服务器端插件系统
大型应用(数据库、API 网关、消息队列)经常需要支持用户自定义逻辑——如自定义认证、自定义路由、自定义数据转换。传统方案是嵌入 Lua/JavaScript 解释器,但解释型语言的性能受限。
WASI 提供了一个替代方案:用户用 Rust/C/Go 编写插件,编译为 WASI 组件,应用用 Wasmtime 加载执行。编译型语言通常比解释执行快得多(具体倍数取决于负载),同时 WASI 的能力安全保证插件不会越权——即使插件包含恶意代码,也只能访问宿主显式授权的资源。
跨平台命令行工具
命令行工具通常需要在不同操作系统(Linux/macOS/Windows)上运行——需要为每个平台编译和分发单独的二进制文件。WASI 提供了一个替代方案:编译一次为 WASI 组件,任何支持 WASI 的运行时都可以执行。
但这里有一个限制:WASI Preview 2 不支持所有操作系统功能——没有 fork/exec、没有信号处理、没有终端控制。只有"纯计算 + 文件/网络 I/O"类型的命令行工具适合编译为 WASI——如文本处理工具、数据格式转换工具、代码生成器。
13.11 WASI 与容器编排:runwasi / krustlet / Spin
WASI 组件要在生产环境运行,需要和现有的容器编排基础设施集成——Kubernetes、Nomad、Docker Compose。这部分生态在 2024-2026 年快速成熟,理解关键工具的定位有助于做架构选型。
13.11.1 三种集成路径
graph TD A["WASI 组件部署需求"] --> B["路径一:Docker + WASM"] A --> C["路径二:原生 Pod runtime"] A --> D["路径三:专属 PaaS"] B --> B1["runwasi 的 containerd shim<br/>(containerd-shim-wasm 库)<br/>沿用现有 Docker/K8s 工具链"] C --> C1["krustlet (kubelet 替代,已停更)<br/>youki 等 OCI runtime 的 WASM 支持"] D --> D1["Fermyon Spin<br/>Cosmonic wasmCloud<br/>Cloudflare Workers<br/>专为 WASM 设计的运行时"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333
13.11.2 路径一:runwasi 的 containerd shim(生产首选)
containerd-shim-wasm 是 runwasi 项目(containerd 的非核心子项目,见 runwasi README 的 Project Details)提供的 Rust 库,各运行时的 shim(如跑 Wasmtime 的 containerd-shim-wasmtime,containerd 里的 runtime 名是 io.containerd.wasmtime.v1)都基于它构建——把 WASM 模块当成"容器"来管理,让 Docker/Kubernetes 的上层工具不需要改动就能运行 WASI 工作负载。前提是节点上装好 shim、在 containerd 配置里注册对应的 runtime handler,再建一个同名的 RuntimeClass 对象。
部署示例:
# Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: wasm-app
spec:
selector:
matchLabels: { app: wasm-app }
template:
metadata:
labels: { app: wasm-app }
spec:
runtimeClassName: wasmtime # 关键:指向已注册 WASM shim 的 RuntimeClass
containers:
- name: app
image: registry.example.com/my-wasi-app:1.0 # OCI 镜像,但内容是 .wasm
OCI 镜像构建:
FROM scratch
COPY my-app.wasm /app.wasm
ENTRYPOINT ["/app.wasm"]
优势:复用 Kubernetes 的所有能力——deploy/replicaset/HPA/service/ingress 全部可用。运维团队不需要学新工具——它们看到的是"一种特殊的容器"。
13.11.3 路径二:替换 kubelet 或 OCI runtime
另一类方案是在更低的层次换掉组件:
| 工具 | 集成层 | 状态(2026-09-24 核对 GitHub) | 适用 |
|---|---|---|---|
| krustlet | kubelet | 仓库最后一次推送在 2023-10,已停更 | 不推荐新项目 |
| youki + WASM | OCI runtime | 实验 | 研究用途 |
对照之下,runwasi 走的是 containerd shim 这一层(路径一):同样用 OCI 镜像分发,节点上多装一个 shim 即可,WASM Pod 与普通 Pod 可以共存;代价是需要修改节点配置、排错需要懂 containerd 内部。
13.11.4 路径三:Spin / wasmCloud(PaaS 路线)
如果不想自己运维基础设施,专属 PaaS 是最快的路径:
Fermyon Spin:本地开发体验类似 Vercel,内建 HTTP/Redis trigger(另有 SQLite、KV 等宿主能力),用 WASI 组件作为 handler(详见第 17 章 §17.2):
# spin.toml
spin_manifest_version = 2
[application]
name = "my-app"
[[trigger.http]]
route = "/api/..."
component = "api"
[component.api]
source = "target/wasm32-wasip2/release/api.wasm"
spin deploy # 部署到 Spin 的托管平台(Fermyon 已于 2025-12 被 Akamai 收购,托管服务现状以官方文档为准)
wasmCloud:CNCF 孵化项目,编排 WASM 组件(早期版本叫 actor),组件之间通过 WIT 接口调用;v2 改以 Kubernetes Operator 为运行底座,已经不属于「免运维」一类(见第 17 章 §17.14.3)。
适用:Spin 这类托管路线适合早期项目快速验证、不愿运维 K8s 的团队;wasmCloud 适合已在用 Kubernetes 的团队。
13.11.5 路径选择决策
flowchart TD
A["要部署 WASI 应用"] --> B{"已有 K8s 集群?"}
B -->|是| C{"团队有 WASM 经验?"}
C -->|是| D["路径一:runwasi shim<br/>逐步给节点加装"]
C -->|否| E["先在少量节点试点 runwasi shim<br/>风险最低"]
B -->|否| F{"运维成本敏感?"}
F -->|是| G["路径三:Spin/wasmCloud"]
F -->|否| H["从 K8s 起步<br/>+ runwasi shim"]
style E fill:#10b981,color:#333
style G fill:#f59e0b,color:#333
已有 K8s 的团队多半应走路径一——复用现有 K8s 工具链是最大的运维节约;Spin 这类 PaaS 适合不愿运维基础设施的团队。
13.12 WASI 实战:常见错误与诊断
WASI 在生产环境最容易出错的不是逻辑——而是宿主与 Guest 的能力授权。错误的能力配置导致运行时报错,调试链路比传统应用复杂得多。
13.12.1 错误一:能力未授权
症状:组件里的标准库调用返回错误——没有对应的预打开目录时文件操作报 NotFound(No such file or directory),权限位不够时报 PermissionDenied(Operation not permitted),网络未授权时 socket 调用报 PermissionDenied(以上都是用 1.96.0 编的 wasm32-wasip2 程序在 wasmtime run 下的实测输出)。
根因:组件代码尝试访问宿主未授权的能力(文件、网络、环境变量)。WASI 默认 deny-all——必须显式授权。
// Wasmtime 嵌入端:明确授权
let mut wasi_ctx = WasiCtxBuilder::new()
.preopened_dir("/tmp/sandbox", "/", DirPerms::all(), FilePerms::all())?
.inherit_network() // 允许网络
.inherit_env() // 允许读环境变量
.build();
诊断技巧:打开 wasmtime-wasi 的 trace 日志(docs/cli-logging.md),每个 WASI 调用的参数和返回值都会打出来,发布版的 wasmtime 二进制就能用:
WASMTIME_LOG=wasmtime_wasi=trace wasmtime run my_component.wasm
# 实测输出形如:
# TRACE wit-bindgen import{module="types" function="[method]descriptor.open-at"}: ... call ... path="input.txt" ...
13.12.2 错误二:stdout/stderr 不可见
症状:组件代码用 println! 输出但日志看不到。
根因:wasmtime run 会把组件的标准流接到自己的标准流上,但嵌入 API 不会——WasiCtxBuilder 的默认值是 stdin 关闭、stdout/stderr 把写入的内容全部丢掉(见 WasiCtxBuilder::new 的文档注释),忘了 inherit_stdout()/inherit_stderr() 就什么都看不到。另外,容器化部署中宿主进程自己的 stdout 也可能被丢弃或重定向到 /dev/null。
修复:显式配置 stdio 流,要么 inherit_stdout(),要么收集到内存里:
// 嵌入端把 stdout 收集到内存(wasmtime_wasi::p2::pipe::MemoryOutputPipe,参数是容量上限)
let stdout = MemoryOutputPipe::new(1024 * 1024);
let mut wasi_ctx = WasiCtxBuilder::new()
.stdout(stdout.clone())
.build();
// 执行后读取
let output = stdout.contents();
或在 K8s 部署中确保 stdout 流向日志收集器(Fluent Bit / Vector)。
13.12.3 错误三:路径混淆(preopen 与组件视角)
症状:组件读 /data/file.txt 报 not found,但宿主上 /data/file.txt 确实存在。
根因:WASI 的文件系统是"沙盒文件系统"——组件看到的路径与宿主路径不同。preopened_dir(host_path, guest_path) 把宿主的 host_path 挂载到组件视角的 guest_path:
// 错误:宿主路径直接给组件
.preopened_dir("/var/data", "/var/data", DirPerms::all(), FilePerms::all())?
// 推荐:明确边界
.preopened_dir("/var/lib/myapp/data", "/data", DirPerms::all(), FilePerms::all())?
组件代码中应使用 guest 视角的路径(/data/file.txt),不应假设宿主路径。
13.12.4 错误四:等待 I/O 时挂起
症状:组件调用 wasi:io/poll 后挂起。
根因:poll 本身是一次宿主调用,组件进了 poll 就已经把执行权交给了宿主——挂起的原因是它等的 pollable 永远不会就绪,比如只等一个对端迟迟不发数据的 socket 流,列表里又没有定时器。WIT 文档给的办法就是把时钟的 pollable 一起放进去:「A timeout can be implemented by adding a pollable from the wasi-clocks API to the list」(crates/wasi/src/p2/wit/deps/io.wit 中 poll 的文档注释),写法见 §13.6 的 read_with_timeout。
顺带澄清一个常见的误判:Rust 里忘了 .await 不会造成挂起——没被 poll 的 future 根本不会执行(编译器通常会给出未使用的警告),症状是「请求没发出去」,不是「卡住」。
诊断:epoch 中断(§13.8)只在 WASM 代码执行时检查截止时间,组件停在 poll 这个宿主调用里时它不起作用;它适合抓的是死循环类的「挂起」:
config.epoch_interruption(true); // 必须在用 config 创建 Engine 之前设置
// ……Engine::new(&config)、Store::new(...)……
store.set_epoch_deadline(1); // 1 个 epoch 后中断
// 在另一个线程定期 engine.increment_epoch()
13.12.5 错误五:组件版本与 Wasmtime 不匹配
症状:组件实例化报 component imports instance `wasi:…@x.y.z`, but a matching implementation was not found in the linker,或类型不匹配。
根因:0.2.x 之内按 semver 兼容(§13.2):Wasmtime 的 Linker 会把组件导入的 @0.2.x 匹配到宿主注册的另一个 0.2 小版本上,所以版本号不同本身通常不是问题——实测用 wasip2 1.0.4(WASI 0.2.12)编出的组件,在内置 0.2.6 的 Wasmtime 44 上照常运行。真正会失败的是:
- 跨 0.x 大版本:0.2 的组件与只提供 0.3(或 0.3 的 rc)的宿主互不相认,反之亦然;
- 宿主压根没注册那个接口:最常见的是
wasmtime serve不加-Scli时只有wasi:http/proxyworld 的接口,而 Rust std 编出的组件还导入了wasi:cli/environment等(§13.5); - 组件用到了宿主那一版还没有的新函数:
@since标注比宿主版本新的函数,宿主里查不到。
修复:先用 wasm-tools component wit x.wasm 看清组件到底导入了哪些接口、哪个版本,再对照宿主。依赖版本的对应关系(crates.io 与 Wasmtime 仓库的 git tag,2026-09-24 核对):
| WASI 版本 | wasi / wasip2 crate |
Wasmtime 内置的 WIT |
|---|---|---|
| 0.2.0 | wasi 0.13.0–0.13.1 |
17–24 |
| 0.2.1 | wasi 0.13.2 |
25–26 |
| 0.2.2 | wasi 0.13.3 |
27–28 |
| 0.2.3 | wasi 0.14.0–0.14.1 |
29–34 |
| 0.2.4 | wasi 0.14.2–0.14.7、wasip2 1.0.0–1.0.1 |
—(35 起直接跳到 0.2.6) |
| 0.2.6 | — | 35–44 |
| 0.2.12 | wasip2 1.0.4、2.0.x |
— |
(「Wasmtime 内置的 WIT」一栏只表示该版本自带哪一版接口定义;因为有 semver 匹配,它同样能运行导入更早 0.2.x 的组件。)CI 中应固定所有版本号——任何升级都跑完整集成测试。
13.12.6 诊断工具清单
| 工具 | 用途 |
|---|---|
wasmtime run --invoke '<func>(<参数>)' |
直接调用组件导出函数(组件的调用表达式用 WAVE 语法,见 docs/cli-options.md),最小化变量 |
wasm-tools component wit |
查看 .wasm 的 WIT 接口 |
wasm-tools validate |
验证 .wasm 二进制合法性 |
wasmtime serve |
本地起 HTTP 服务测试 wasi:http 组件 |
WASMTIME_LOG=wasmtime_wasi=trace |
打出每个 WASI 调用的参数与返回值(发布版二进制即可用) |
诊断的核心原则:先验证组件本身合法,再验证能力配置,最后才是业务逻辑。倒过来排查会浪费大量时间在错误的方向上。
13.13 wasi-nn:神经网络推理的标准接口
ML 推理是 WASM 在服务端最有潜力的场景之一——但 Preview 2 标准接口集合中并不直接包含 ML 算子。wasi-nn 是独立提案,定义了 WASM 模块调用 ML 推理后端(ONNX Runtime / OpenVINO / TensorFlow Lite)的标准接口。
13.13.1 设计动机
graph TD
A["WASM 模块"] --> B{"ML 推理路径?"}
B --> C["纯 WASM 实现<br/>tract/burn/ndarray"]
B --> D["wasi-nn 调用宿主"]
C --> C1["体积大(引擎 + 权重都在模块里)<br/>性能:CPU only<br/>跨平台一致"]
D --> D1["体积小(只剩接口胶水)<br/>性能:可用宿主 GPU/NPU<br/>依赖宿主能力"]
style C fill:#f59e0b,color:#333
style D fill:#10b981,color:#333
纯 WASM 推理的问题:
- 把 ONNX Runtime / TensorFlow 这类推理引擎整个编进 WASM,产物体积很大
- 无法利用宿主 GPU/NPU——只能纯 CPU(WASI 侧没有通用 GPU 接口,
wasi-webgpu还在 Phase 2) - 模型权重必须打包进 WASM 模块或运行时上传
wasi-nn 解决:把推理委托给宿主——WASM 模块只描述"输入张量 + 模型 ID",实际推理在宿主完成。
13.13.2 wasi-nn 的核心 WIT 接口
以 Wasmtime 44 仓库内的 crates/wasi-nn/wit/wasi-nn.wit 为准(注意它还没定版,包名带 rc 后缀):
package wasi:nn@0.2.0-rc-2024-10-28;
interface tensor {
type tensor-dimensions = list<u32>;
enum tensor-type { FP16, FP32, FP64, BF16, U8, I32, I64 }
type tensor-data = list<u8>;
resource tensor {
constructor(dimensions: tensor-dimensions, ty: tensor-type, data: tensor-data);
dimensions: func() -> tensor-dimensions;
ty: func() -> tensor-type;
data: func() -> tensor-data;
}
}
interface graph {
enum graph-encoding { openvino, onnx, tensorflow, pytorch, tensorflowlite, ggml, autodetect }
enum execution-target { cpu, gpu, tpu }
type graph-builder = list<u8>;
resource graph {
init-execution-context: func() -> result<graph-execution-context, error>;
}
load: func(builder: list<graph-builder>, encoding: graph-encoding, target: execution-target)
-> result<graph, error>;
load-by-name: func(name: string) -> result<graph, error>;
}
interface inference {
type named-tensor = tuple<string, tensor>;
// 这一版把 set-input / compute / get-output 三步合成了一次调用
resource graph-execution-context {
compute: func(inputs: list<named-tensor>) -> result<list<named-tensor>, error>;
}
}
工作流:
load-by-name(宿主预注册的模型)或load(自带权重字节)→ 得到graph资源init-execution-context()→graph-execution-context- 把输入打包成
list<named-tensor>一次性传给compute() - 从返回的
list<named-tensor>里取输出
早期版本的 set-input / 无参 compute / get-output 三段式已经被这个单次调用取代——照着旧教程写会编译不过。
13.13.3 Rust 侧使用示例
下面是示意写法(未实际编译):模块路径取决于你用 wit-bindgen 从上面的 WIT 生成绑定时的配置,WIT 里的 FP32 枚举项在 Rust 绑定里是 Fp32。
use wasi_nn::wasi::nn::{graph, tensor};
fn predict(image_data: &[u8]) -> Result<Vec<f32>, String> {
// 加载模型(宿主预先注册)
let g = graph::load_by_name("mobilenet-v2")
.map_err(|e| format!("加载失败: {e:?}"))?;
let ctx = g.init_execution_context()
.map_err(|e| format!("上下文创建失败: {e:?}"))?;
// 输入张量:1x224x224x3 RGB 图像
let input = tensor::Tensor::new(
&vec![1, 224, 224, 3],
tensor::TensorType::Fp32,
&image_data.to_vec(),
);
// 一次调用完成"喂输入 + 算 + 取输出"
let outputs = ctx
.compute(vec![("input".to_string(), input)])
.map_err(|e| format!("推理失败: {e:?}"))?;
let (_name, out) = outputs.into_iter().next().ok_or("没有输出张量")?;
Ok(bytemuck::cast_slice(&out.data()).to_vec())
}
13.13.4 宿主侧实现(Wasmtime + ONNX Runtime)
use wasmtime_wasi_nn::wit::WasiNnCtx;
// 每项是 (编码格式, 模型目录),目录名就是 guest 侧 load-by-name 用的名字
fn create_wasi_nn_ctx(graphs: &[(String, String)]) -> wasmtime::Result<WasiNnCtx> {
// preload 内部:列出可用后端、建一个 InMemoryRegistry、逐个 registry.load(...)
let (backends, registry) = wasmtime_wasi_nn::preload(graphs)?;
// 注意签名是 new(backends, registry) 两个参数,没有 register_graph 这种方法
Ok(WasiNnCtx::new(backends, registry))
}
CLI 侧的等价写法见 crates/cli-flags/src/lib.rs 里 nn_graph 的文档注释——--wasi-nn-graph openvino:/foo/bar 会把该目录预加载成名为 bar 的模型。
(wasmtime_wasi_nn 分成 wit(组件模型)和 witx(WASIp1)两套入口,各有自己的 WasiNnCtx 与 add_to_linker——用组件就选 wit。)
宿主预先把 .onnx 模型注册到 wasi-nn 上下文——WASM 模块通过名字引用,不需要持有模型字节。这种"模型在宿主、计算分离"的架构有几个优势:
- 多个 WASM 实例共享同一个模型:模型只加载一次,节省内存
- WASM 模块体积小:模型不在 WASM 内
- 宿主可以优化:选择最优 backend(GPU / NPU / CPU),不在 WASM 中决定
13.13.5 后端实现的差异
wasi-nn 是接口规范,不规定后端(WIT 的 graph-encoding 枚举列了 openvino / onnx / tensorflow / pytorch / tensorflowlite / ggml)。Wasmtime 44 的 wasmtime-wasi-nn 实际实现的后端是 OpenVINO、ONNX Runtime、PyTorch 和 Windows 上的 WinML(crates/wasi-nn/src/backend/),没有 TensorFlow Lite。常见后端:
| 后端 | 平台 | 性能特点 | 适用 |
|---|---|---|---|
| ONNX Runtime | 跨平台 + GPU | 通用,社区大 | 推荐默认 |
| OpenVINO | Intel CPU/iGPU | Intel 平台最优 | Intel 服务器 |
| TensorFlow Lite | 移动端 | 轻量、量化模型 | 边缘设备 |
| PyTorch (libtorch) | 跨平台 + GPU | 训练-推理对齐 | 内部使用 |
切换 backend 不需要改 WASM 模块——只需要改宿主配置。这是 wasi-nn 最大的工程价值:业务代码与具体推理引擎解耦。
13.13.6 性能数据
几种方案的量级关系(以 MobileNet 级别的单图推理为例;绝对值随模型、后端、硬件差异极大,本节不给具体数字,要数就在自己的目标机型上量):
| 方案 | 推理耗时 | WASM 产物体积 | 说明 |
|---|---|---|---|
纯 WASM(把推理引擎整个编进模块,如 tract) |
最慢 | 最大:整个推理引擎 + 权重都在模块里 | 完全自包含,不依赖宿主能力 |
| wasi-nn + CPU 后端 | 明显快于纯 WASM | 最小:模块里只剩接口胶水 | 推理跑在宿主的原生引擎里 |
| wasi-nn + GPU 后端 | 最快 | 同上 | 额外吃到 GPU 加速 |
| 原生进程 + 同一推理引擎 | 与 wasi-nn CPU 同量级 | 不适用 | 用来确认 wasi-nn 没引入额外量级开销 |
两个结论是稳定的:一是体积——把推理引擎留在宿主侧之后,WASM 模块从"装着整个引擎"缩到"只有接口胶水",差着几个数量级,冷启动也随之改善;二是耗时——wasi-nn 走宿主原生引擎,和原生进程在同一量级,而把引擎编进 WASM 会明显更慢。
13.13.7 适用边界
wasi-nn 的取舍:
| 优势 | 限制 |
|---|---|
| 模块体积小、冷启动快 | 依赖宿主预注册模型 |
| 利用宿主 GPU/NPU | load-by-name 只能用宿主预注册的模型(guest 用 load 自带权重可行,但失去共享与体积优势) |
| 多实例共享模型 | 模型版本管理与 WASM 版本耦合 |
| 抽象屏蔽 backend | 高级算子(自定义层)受限 |
适合:业务模型库相对固定(如 100 个标准模型)、宿主可控、追求极致性能。不适合:用户上传任意模型、模型频繁变化、需要训练/微调能力。
下游平台对 wasi-nn 的支持程度各不相同,选用前以其当期文档为准。wasi-nn 代表的是 WASM 在 ML 服务化场景的标准化方向。
13.14 WASI Preview 3:原生异步的演进路径
WASI Preview 2 用 wasi:io/poll 实现异步——但语义复杂、消费者代码冗长。Preview 3 把异步交给组件模型本身:WIT 里可以写 async func,stream<T>、future<T> 成为内置类型,异步成为接口的一等概念。
规范状态(WebAssembly/WASI 仓库 GitHub Releases 与 Bytecode Alliance 公告,2026-09-24 核对):经过 0.3.0-rc-2026-01-06、-02-09、-03-15 三个候选版,WASI 子组于 2026-06-11 投票通过 WASI 0.3.0 正式版;2026-08-11 又发布了 0.3.1(组件模型的 implements 与 map<K, V> 开始可用),发布说明称 0.3.x 此后每两个月一版、排期到 2027 年。0.3 里 wasi:io 整包退场:pollable → future<T>,input-stream/output-stream → stream<u8>,poll(list<pollable>) → 对 future 的 await,start-foo/finish-foo 成对函数 → 一个 async func。
本专栏 pin 的实现状态则落后一步:Wasmtime 44(2026-04-20)带的是 rc 版的 p3 实现(crates/wasi/src/p3/),WIT 包版本写的是 wasi:cli@0.3.0-rc-2026-03-15(crates/wasi/src/p3/wit/deps/cli.wit:1,clocks/filesystem/random/sockets 同 rc 号),而且默认关闭——src/common.rs:20 的 P3_DEFAULT 为 false,要 -Sp3 并开启组件模型异步才会注册。
至于之后的版本,Bytecode Alliance 的 0.3 公告(bytecodealliance.org/articles/WASI-0.3,2026-06-11)说「Wasmtime 46 will ship WASI 0.3.0 with Component Model Async enabled by default」——这对 pin 版本而言是预告,不是本专栏验证过的事实。Rust 侧有 wasm32-wasip3 目标,但只是 Tier 3(compiler/rustc_target/src/spec/targets/wasm32_wasip3.rs 的 tier: Some(3)),1.96.0 的平台文档写明标准库暂不真正使用 WASIp3 API(src/doc/rustc/src/platform-support/wasm32-wasip3.md 的 Note,写于 2025-10-01)。
13.14.1 Preview 2 vs Preview 3 的异步表达
graph LR
subgraph "Preview 2: pollable + 同步签名"
A1["incoming-request"] --> A2["consume body<br/>返回 input-stream"]
A2 --> A3["pollable<br/>显式 poll"]
A3 --> A4["read 数据"]
end
subgraph "Preview 3: async fn 一等"
B1["incoming-request"] --> B2["async consume body<br/>返回 stream<u8>"]
B2 --> B3["async iterator<br/>语言原生"]
end
style A3 fill:#f59e0b,color:#333
style B2 fill:#10b981,color:#333
WIT 的语法变化,以 wasi:http 的处理器为例(Wasmtime 44 仓库里的 0.2.6 与 0.3.0-rc-2026-03-15 两版 WIT,节选):
// Preview 2:crates/wasi-http/wit/deps/http.wit
interface incoming-handler {
handle: func(request: incoming-request, response-out: response-outparam);
}
// Preview 3:crates/wasi-http/src/p3/wit/deps/http.wit:440
interface handler {
handle: async func(request: request) -> result<response, error-code>;
}
P2 靠 response-outparam 这个「回填槽」把响应交回宿主,P3 直接把响应当 async func 的返回值;0.3 还把 proxy world 换成了 service 与 middleware 两个 world。
async 关键字让 WIT 工具链生成的绑定自动用语言的原生异步——Rust 是 async fn,Python 是 async def,JS 是 Promise/async function。消费者代码不再需要手动管理 pollable。
13.14.2 Stream 与 Future:异步原语
Preview 3 的两个核心异步类型:
| 类型 | 语义 | 对应语言原语 |
|---|---|---|
future<T> |
单次异步结果 | Rust Future<Output=T> / JS Promise<T> |
stream<T> |
异步序列 | Rust Stream<Item=T> / JS AsyncIterator<T> |
示意(虚构的 processor 接口,用来说明两种类型):
interface processor {
// future:单次结果
fetch: async func(url: string) -> result<string, error>;
// stream:异步序列
process-batch: async func(items: list<string>) -> stream<result-item>;
}
Rust 侧(消费者代码):
// future - 自然的 async/await
let result = processor::fetch(&url).await?;
// stream - 自然的 async iteration(process-batch 本身是 async func,先 await 拿到 stream)
let mut stream = processor::process_batch(&items).await;
while let Some(item) = stream.next().await { // wit-bindgen 0.57 的 StreamReader::next
handle(item?);
}
对比 Preview 2 的同等代码——大量手动 pollable 管理被消除,可读性显著提升。
13.14.3 实施进度与时间表
timeline
title WASI 0.3 时间线(WebAssembly/WASI Releases,2026-09-24 核对)
2026-01-06 : 0.3.0-rc-2026-01-06
2026-02-09 : 0.3.0-rc-2026-02-09
2026-03-15 : 0.3.0-rc-2026-03-15(Wasmtime 44 实现的版本)
2026-06-11 : WASI 0.3.0 正式版
2026-08-11 : WASI 0.3.1
核对日(2026-09-24)的状态:
| 工具 | Preview 3 支持 | 依据 |
|---|---|---|
| 规范 | 0.3.0(2026-06-11)、0.3.1(2026-08-11)已发布 | WebAssembly/WASI GitHub Releases |
| Wasmtime 44(pin) | 带 rc 版 p3 实现,默认关闭 | crates/wasi/src/p3/wit/deps/*.wit 的包版本 0.3.0-rc-2026-03-15;src/common.rs:20;44.0.0 还加了 wasi:tls 的 0.3.0-draft 实现(RELEASES.md 的 Added 一节) |
| Wasmtime 后续版本 | 公告称 46 默认启用 0.3.0(预告) | Bytecode Alliance「WASI 0.3」公告(2026-06-11) |
| Rust 工具链(1.96.0) | wasm32-wasip3 目标存在,Tier 3 |
compiler/rustc_target/src/spec/targets/wasm32_wasip3.rs;平台文档注明标准库暂不真正使用 WASIp3 API |
| wit-bindgen 0.57(pin) | Rust 绑定支持 async func、stream/future |
crates/guest-rust/Cargo.toml 默认 feature 含 async;crates/guest-rust/src/rt/async_support/stream_support.rs:540 的 StreamReader::next |
| cargo-component | 0.21.1(2025-04-07)后未再发版 | crates.io / GitHub |
| jco(JS 工具链) | 公告称已支持全部 WASI 0.3 | 同上公告 |
⚠️ 规范定稿不等于你手里的运行时跟上了:Wasmtime 44 实现的是 -rc-2026-03-15,与 0.3.0 正式版的包版本不同,按 0.3.0 正式版 WIT 编出的组件不能指望在 44 上运行;生产代码要按你实际部署的运行时版本来选 WIT。
13.14.4 Preview 2 → Preview 3 的迁移路径
flowchart LR
A["现有 Preview 2 代码"] --> B["WASI 0.3.0 定稿(2026-06)<br/>等运行时跟进"]
B --> C{"破坏性变更?"}
C -->|否| D["逐步替换 pollable 为 async"]
C -->|是| E["保留 P2 + 新代码用 P3"]
D --> F["完整迁移"]
E --> G["双版本共存"]
style F fill:#10b981,color:#333
style G fill:#f59e0b,color:#333
迁移策略:
- 小项目:等所用运行时默认支持 0.3 后整体替换
- 大项目:双版本共存,新功能用 P3,旧代码渐进迁移
- 生态项目(开源 lib):先发 P2 + P3 兼容版,再移除 P2
13.14.5 Preview 3 之后
0.3 之后的节奏已经写进发布说明:0.3.x 每两个月一版(0.3.1 的发布说明,排期到 2027 年),每一版可以采纳组件模型的新特性(0.3.1 采纳了 implements 与 map<K, V>),但运行时和工具链必须同步支持才算兼容。接口层面,WASI 仓库 docs/Proposals.md(2026-09-24 核对)列出:进入 Phase 3 的只有 clocks、random、filesystem、sockets、cli、http 六个;key-value、wasi-nn、runtime config、messaging、WebGPU 等在 Phase 2;blob store、logging、SQL、threads、TLS、OTel 等还在 Phase 1。
理解这条演进路径有助于做长期技术选型——P2 里直接依赖 wasi:io 的 pollable/流的代码,到 0.3 都要改写成 future/stream。
13.14.6 当前的工程建议
flowchart TD
A["2026 工程决策"] --> B{"项目类型?"}
B -->|生产稳定服务| C["Preview 2 + Wasmtime"]
B -->|新项目愿意冒险| D["Preview 3(先确认运行时版本)"]
B -->|长期演进项目| F["P2 起步,留 P3 接口"]
style C fill:#10b981,color:#333
style D fill:#f59e0b,color:#333
style F fill:#6366f1,color:#fff
在 pin 的 Wasmtime 44 上,生产项目仍应该用 Preview 2——它是运行时默认启用的那一版,文档与生态也最成熟。0.3 规范虽已定稿,但要等所用运行时默认启用 0.3.0 正式版之后,异步密集的新项目才值得切过去。
13.15 WASI 标准接口生态:超越 HTTP
wasi:http(§13.5)和 wasi-nn(§13.13)是 WASI 接口的两个亮点——但 WASI 标准接口家族远不止这两个。理解全貌有助于把握 WASI 的演进方向,避免重复造轮子。
13.15.1 WASI 标准接口全景
graph TD A["WASI 标准接口"] --> B["核心系统"] A --> C["数据存储"] A --> D["网络"] A --> E["计算"] A --> F["可观测"] B --> B1["wasi:filesystem<br/>wasi:clocks<br/>wasi:random<br/>wasi:io"] C --> C1["wasi:keyvalue(Phase 2)<br/>wasi:blobstore(Phase 1)<br/>wasi:sql(Phase 1)"] D --> D1["wasi:http<br/>wasi:sockets<br/>wasi:messaging(Phase 2)"] E --> E1["wasi-nn(Phase 2)<br/>wasi-threads(Phase 1)"] F --> F1["wasi:logging<br/>wasi:otel(Phase 1)"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333 style E fill:#dc2626,color:#fff
13.15.2 wasi:keyvalue:标准化的 KV 存储
wasi:keyvalue 提供跨实现的 KV 抽象——目标是同一份 WASM 代码可以换不同的存储后端跑:
以 Wasmtime 44 内的 crates/wasi-keyvalue/wit/deps/keyvalue/*.wit 为准(注意包版本带 -draft,还没定版):
package wasi:keyvalue@0.2.0-draft;
interface store {
variant error { no-such-store, access-denied, other(string) }
record key-response { keys: list<string>, cursor: option<u64> }
open: func(identifier: string) -> result<bucket, error>;
resource bucket {
get: func(key: string) -> result<option<list<u8>>, error>;
set: func(key: string, value: list<u8>) -> result<_, error>;
delete: func(key: string) -> result<_, error>;
exists: func(key: string) -> result<bool, error>;
list-keys: func(cursor: option<u64>) -> result<key-response, error>;
}
}
interface batch {
use store.{bucket, error};
get-many: func(bucket: borrow<bucket>, keys: list<string>)
-> result<list<option<tuple<string, list<u8>>>>, error>;
set-many: func(bucket: borrow<bucket>, key-values: list<tuple<string, list<u8>>>)
-> result<_, error>;
delete-many: func(bucket: borrow<bucket>, keys: list<string>) -> result<_, error>;
}
interface atomics {
use store.{bucket, error};
// delta 和返回值都是 u64,不是有符号数
increment: func(bucket: borrow<bucket>, key: string, delta: u64) -> result<u64, error>;
}
Rust 侧用法(示意:wasi crate 只含 WASI 0.2 的核心接口,不含 keyvalue,绑定要自己用 wit-bindgen 从上面的 WIT 生成,模块路径随生成配置而定):
use crate::bindings::wasi::keyvalue::store::open;
fn cache_value(key: &str, value: &[u8]) -> Result<(), String> {
let bucket = open("default").map_err(|e| format!("{:?}", e))?;
bucket.set(key, value).map_err(|e| format!("{:?}", e))
}
宿主侧决定绑定到哪种存储——业务代码不变,部署平台决定后端。但就 pin 的 Wasmtime 44 而言,wasmtime-wasi-keyvalue 目前只支持内存后端,而且只认空字符串这个 identifier(crates/wasi-keyvalue/src/lib.rs:7-8 的模块文档列出的唯一后端就是 in-memory、identifier 为空),上面 open("default") 在它上面会返回 no-such-store(同文件 :164-170);它在 docs/stability-tiers.md 里也标为 unstable proposal。
13.15.3 wasi-sockets:标准 TCP/UDP
wasi-sockets 让 WASM 模块直接操作网络 socket——比 wasi:http 更底层:
package wasi:sockets@0.2.6;
interface tcp {
resource tcp-socket {
// P2 把可能阻塞的操作拆成 start-/finish- 两步,中间用 subscribe() 拿 pollable 等待
start-bind: func(network: borrow<network>, local-address: ip-socket-address) -> result<_, error-code>;
finish-bind: func() -> result<_, error-code>;
start-listen: func() -> result<_, error-code>;
finish-listen: func() -> result<_, error-code>;
accept: func() -> result<tuple<tcp-socket, input-stream, output-stream>, error-code>;
}
}
适用场景:
| 场景 | wasi:http | wasi-sockets |
|---|---|---|
| HTTP 客户端/服务端 | ✓ 推荐 | ✗ 太底层 |
| WebSocket 服务 | ✗ | ✓ 唯一选择 |
| 自定义协议(gRPC/MQTT) | ✗ | ✓ |
| TCP 长连接 | ✗ | ✓ |
澄清一个常见误解:wasi:sockets 不是实验性的——它和 wasi:io/wasi:clocks/wasi:filesystem/wasi:random/wasi:http 一起被 Wasmtime 44 列在 Tier 1(docs/stability-tiers.md),随 WASI 0.2 一同稳定。真正需要注意的是:地址默认全部拒绝,宿主必须显式授权(-S inherit-network / socket_addr_check 等,见 §13.8);另外浏览器侧没有对应规划。
13.15.4 wasi:logging 与 wasi-otel
可观测性的标准化:
// 注意 wasi:logging 仍在 Phase 1,包版本请以你实际引入的 WIT 为准
package wasi:logging;
interface logging {
enum level {
trace, debug, info, warn, error, critical,
}
log: func(level: level, context: string, message: string);
}
这套 API 让 WASM 模块发日志而不绑定具体后端——宿主决定日志去 stdout、syslog、还是 OpenTelemetry。
指标与追踪方向,WASI 仓库目前挂的是 wasi-otel 提案(OpenTelemetry,v0.2.10 的发布说明里从 Phase 0 进到 Phase 1),并没有一个叫 wasi:metrics 的提案。
graph LR
A["WASM 业务代码"] --> B["wasi:logging<br/>抽象接口"]
B --> C{"宿主决定后端"}
C --> D["stdout(开发)"]
C --> E["syslog(生产)"]
C --> F["OpenTelemetry(云)"]
style B fill:#10b981,color:#333
13.15.5 接口选择决策
flowchart TD
A["WASM 模块需要的能力"] --> B{"什么类型?"}
B --> C["持久化"]
B --> D["网络通信"]
B --> E["计算加速"]
B --> F["可观测"]
C --> C1{"结构化?"}
C1 -->|KV| C2["wasi:keyvalue"]
C1 -->|大对象| C3["wasi:blobstore"]
C1 -->|关系| C4["wasi:sql 或外部"]
D --> D1{"协议?"}
D1 -->|HTTP| D2["wasi:http"]
D1 -->|TCP/UDP| D3["wasi:sockets"]
D1 -->|消息队列| D4["wasi:messaging"]
E --> E1["wasi-nn / wasi-threads"]
F --> F1["wasi:logging / wasi-otel"]
style C2 fill:#10b981,color:#333
style D2 fill:#10b981,color:#333
style E1 fill:#10b981,color:#333
13.15.6 标准化的工程价值
graph LR A["WASI 标准接口的价值"] --> B["代码可移植"] A --> C["生态共享"] A --> D["供应链解耦"] B --> B1["同一 .wasm 跑遍所有支持的运行时"] C --> C1["wasi:keyvalue 模块库可被任何项目复用"] D --> D1["业务代码不绑定 AWS/GCP/Azure"] style A fill:#6366f1,color:#fff
WASM + WASI 的最大价值是"接口先于实现"——业务代码用标准 API,部署到不同平台时只换 host 实现。这是 Cloud Native 时代的"write once, run anywhere"再实现,但比 JVM 更轻量、更安全。
13.15.7 当前的不足与未来
flowchart LR A["WASI 标准接口现状"] --> B["已稳定"] A --> C["实验中"] A --> D["缺失"] B --> B1["filesystem / clocks / random / io / sockets / http<br/>(Wasmtime Tier 1)"] C --> C1["keyvalue / nn / config / messaging / webgpu(Phase 2)<br/>blobstore / tls / logging / otel(Phase 1)"] D --> D1["sql(Phase 1)<br/>metrics(无独立提案)"] style B fill:#10b981,color:#333 style C fill:#f59e0b,color:#333 style D fill:#dc2626,color:#fff
不足主要在:
- 复杂数据库接口:wasi:sql 还在 Phase 1,没有成熟的 ORM 层
- 流式数据处理:
wasi-messaging在 Phase 2,流处理没有标准接口 - GPU 计算:除了 wasi-nn,通用 GPU 接口
wasi-webgpu还在 Phase 2
(阶段均取自 WASI 仓库 docs/Proposals.md,2026-09-24 核对。)这些都是社区正在推进的方向,何时成熟没有官方时间表。生产项目应该在标准接口稳定前用平台特定 API(Cloudflare KV、AWS DynamoDB),等标准成熟后渐进迁移。
13.16 WASI 性能基准与运行时实测对比
WASI 不是单一规范——是一组接口加上多个运行时实现。同样的 Rust 代码在 Wasmtime / Wasmer / WAMR 上跑可能差几倍。理解这些差异是生产选型的基础。
13.16.1 基准测试方法论
graph LR A["WASI 基准测试"] --> B["微观基准"] A --> C["宏观基准"] B --> B1["单 host 调用<br/>fd_read / fd_write / clock_now"] B --> B2["纯计算<br/>SHA-256 / 矩阵乘"] C --> C1["真实工作负载<br/>HTTP 请求处理"] C --> C2["复杂应用<br/>JSON 解析 + 计算"] style B fill:#10b981,color:#333 style C fill:#f59e0b,color:#333
每类测试的价值:
- 微观基准:揭示 host 函数和内存操作的开销
- 宏观基准:反映真实业务的端到端体验
只看一类容易得出错误结论——必须组合。
13.16.2 主流运行时的差异从哪来
跨运行时的性能数字高度依赖机器、运行时版本与编译配置,网上流传的对比表大多既不交代环境也无法复现,本专栏不给具体数字。能稳定成立的是几条方向性的判断:
- 编译后端决定稳态执行速度:LLVM 后端(Wasmer LLVM、WAMR AOT)与 Cranelift(Wasmtime)生成的代码质量接近;Singlepass 这类单遍编译器编译极快、代码明显更慢。
- 编译方式决定冷启动:JIT 现场编译的耗时随模块体积增长;AOT / 预编译(Wasmtime 的 cwasm)把这部分挪到构建期。
- 运行的是组件还是核心模块:只有支持组件模型的运行时(本章用的 Wasmtime)能直接跑
wasm32-wasip2产物;WAMR 这类只跑核心模块的运行时,要么用wasm32-wasip1,要么根本不在同一个比较范围里。所以"同一份 .wasm 跑遍各家运行时"本身就要先打个问号。
13.16.3 host 调用的开销
WASI host 函数调用涉及"从 Guest 切到 Host 再切回 Guest",P2 的调用还要过 Canonical ABI 的参数提升/下降(字符串、列表要在线性内存和宿主之间拷贝)。单次调用通常在纳秒到微秒量级,取决于参数里有没有需要拷贝的数据;运行时之间的差距对真实业务影响有限,因为 host 调用通常不是热路径瓶颈。真正会拉开差距的是调用次数:把 N 次小调用合并成 1 次批量调用(比如一次 blocking-write-and-flush 写 4KB,而不是逐字节写),比换运行时管用得多。
13.16.4 内存访问
线性内存的访问开销取决于运行时怎么做边界检查。Wasmtime 在 64 位平台上默认给每块 32 位线性内存预留 4GiB 虚拟地址空间、再加保护页,于是「32-bit WebAssembly linear memories with 64KiB page sizes will skip almost all bounds checks by default」(crates/wasmtime/src/config.rs:1770-1779 的 memory_reservation 文档与函数签名)——越界访问由硬件缺页捕获,而不是每次访问前比较一次。32 位宿主平台上默认预留只有 10MiB,就得靠显式边界检查。换句话说,缓存命中与否带来的差异对 WASM 和原生代码是一样的,"WASM 访存天然慢几倍"并不成立;真正需要显式检查的是 32 位宿主、memory64,或者你为了缩小地址空间占用主动调小了 memory_reservation。
13.16.5 启动时间分解
冷启动是 FaaS 场景的关键指标——分析其组成:
flowchart LR A["请求到达"] --> B["1. 加载 .wasm 字节"] B --> C["2. 编译/加载机器码"] C --> D["3. 实例化"] D --> E["4. start 函数执行"] E --> F["首次调用导出"] style B fill:#6366f1,color:#fff style C fill:#f59e0b,color:#333 style D fill:#10b981,color:#333
各阶段的量级关系:编译机器码压倒性地占大头,实例化其次,加载字节最小;start 函数的耗时完全取决于模块自己做了什么。§13.10 的实测(Xeon Gold 6240,Wasmtime 44,约 120KB 的命令组件):从文件编译组件 17–44ms,而用 InstancePre 每次「建 Store + 实例化」约 12µs。
AOT 缓存是冷启动优化的关键——构建期 Component::serialize 产出 cwasm,运行时 Component::deserialize,把编译这一大块从请求路径上拿掉。
13.16.6 选型决策
flowchart TD
A["WASI 运行时选型"] --> B{"主要约束?"}
B --> C["执行性能"]
B --> D["启动延迟"]
B --> E["资源占用"]
B --> F["生产稳定"]
C --> G["Wasmer LLVM"]
D --> H["Wasmer Singlepass"]
E --> I["WAMR"]
F --> J["Wasmtime(推荐默认)"]
style J fill:#10b981,color:#333
style G fill:#6366f1,color:#fff
style H fill:#f59e0b,color:#333
style I fill:#dc2626,color:#fff
多数服务端 WASI 项目默认选 Wasmtime——它是组件模型与 WASI 0.2 的参考实现,稳定性、文档、生态最完整;有极致需求时再评估其他运行时(且要确认对方支持组件模型)。
13.16.7 基准测试的工程纪律
flowchart TD A["WASI 性能基准纪律"] --> B["1. 复现测试环境"] A --> C["2. 多次运行取中位数"] A --> D["3. 同一份 .wasm 多运行时"] A --> E["4. 真实业务负载,不仅微观"] A --> F["5. 持续监控,跨版本对比"] A --> G["6. 不孤立看数据,看 P99 / P50"] style A fill:#6366f1,color:#fff
基准数据不严谨容易得出错误结论。"我测出 Wasmer 比 Wasmtime 快 50%"这种声明需要严格场景说明(机器、版本、编译后端、是否预编译、测的是哪个阶段),否则没意义。
13.17 WASI 与传统服务的混合架构
生产团队从传统服务迁到 WASI 不是一夜之间——通常是混合架构共存数月甚至数年。理解这种过渡期的工程模式有助于规划渐进迁移。
13.17.1 三种混合架构模式
graph TD A["WASI 与传统服务的混合"] --> B["边车模式"] A --> C["插件模式"] A --> D["双轨模式"] B --> B1["传统服务为主<br/>WASM 处理特定请求"] C --> C1["传统服务嵌入 WASM 扩展点"] D --> D1["新功能用 WASI<br/>旧功能保留传统"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff style D fill:#f59e0b,color:#333
每种模式的适用:
| 模式 | 适用 | 迁移难度 |
|---|---|---|
| 边车 | 性能敏感的转换/过滤 | 中 |
| 插件 | 已有扩展点的服务 | 低 |
| 双轨 | 新旧功能边界清晰 | 高 |
13.17.2 模式一:边车(Sidecar)
flowchart LR
A["客户端"] --> B["nginx/envoy"]
B --> C{"请求类型?"}
C -->|静态| D["传统 Web 服务"]
C -->|图像处理| E["WASM 边车"]
C -->|API| D
E --> F["返回处理后图像"]
style E fill:#10b981,color:#333
边车模式让特定类型的请求路由到 WASM 服务——其他保持传统。优势:
- 风险隔离:WASM 出问题不影响主服务
- 性能针对性:只对适合 WASM 的场景投资
- 渐进迁移:先一个类型,逐步扩展
实战案例:图像 CDN 添加 WASM 滤镜服务作为边车,传统图像存储和分发不变。
13.17.3 模式二:插件(Plugin)
// 传统 Rust 服务嵌入 WASM 插件能力
struct WebServer {
plugins: HashMap<String, wasmtime::Module>,
engine: wasmtime::Engine,
}
impl WebServer {
async fn handle_request(&self, req: Request) -> Response {
// 1. 查找匹配的插件
if let Some(module) = self.plugins.get(req.path()) {
// 2. 用 WASM 处理
let mut store = wasmtime::Store::new(&self.engine, ());
// 示意:插件若导入了 WASI 或宿主函数,要用 Linker 实例化,而不是空导入列表
let instance = wasmtime::Instance::new(&mut store, module, &[]).unwrap();
// ... 调用 WASM 处理
}
// 3. 回退到原生处理
self.handle_native(req).await
}
}
插件模式让用户能在不停机的情况下添加自定义逻辑——典型应用:API 网关的自定义 filter、CMS 的自定义渲染。
13.17.4 模式三:双轨(Parallel Tracks)
graph TB A["业务功能"] --> B["旧功能集"] A --> C["新功能集"] B --> B1["传统 Java/Go 服务<br/>稳定运行"] C --> C1["WASI 组件<br/>新建功能"] D["统一 API 网关"] --> B1 D --> C1 style B1 fill:#6366f1,color:#fff style C1 fill:#10b981,color:#333
双轨模式:新功能用 WASI 实现,旧功能保留——长期共存,不强制迁移旧功能。
实战考虑:
- 新旧功能的接口契约必须一致(用 OpenAPI 或类似 IDL)
- 监控指标要打通(同一套 Prometheus)
- 部署流水线分别管理
13.17.5 数据共享的挑战
混合架构中,新旧服务共享数据是大挑战:
flowchart TD A["数据共享"] --> B["共享数据库"] A --> C["消息队列"] A --> D["共享缓存"] B --> B1["传统 + WASM 都连同一个 DB<br/>schema 一致"] C --> C1["事件驱动<br/>松耦合"] D --> D1["Redis/Memcached<br/>跨服务共享"] style B fill:#10b981,color:#333 style C fill:#6366f1,color:#fff
共享数据库改动最小——schema 不变,新旧服务都可用;事件驱动更解耦但工程复杂度高。
13.17.6 何时该完全迁移
flowchart TD
A["完全迁移到 WASI?"] --> B{"业务收益?"}
B --> C["性能 / 成本显著改善"]
B --> D["运维复杂度降低"]
B --> E["新功能开发加速"]
B --> F["都不显著"]
C --> G["考虑迁移"]
D --> G
E --> G
F --> H["保持混合"]
style G fill:#10b981,color:#333
style H fill:#6366f1,color:#fff
不要为了"统一技术栈"而强行完全迁移——混合架构的成本(运维两套)vs 完全迁移的成本(重写所有服务)应该明确算账。
实践经验:完全迁移很少必要——大多数项目长期维持混合架构最划算。
13.18 WASI Preview 2 生产事故案例
理论和文档无法替代真实事故经验。这一节用 2 个示意性的事故复盘说明两类常见坑——从症状到根因到修复。案例里的数字只用来说明量级关系,不是可复现的基准。
13.18.1 事故 1:fuel 限制配置错误导致全站 50% 超时
症状:某 WASI 服务上线后 P99 从 50ms 飙到 30s,约 50% 请求超时。
症状特征:
flowchart LR
A["请求"] --> B{"WASM 处理"}
B --> C["快速完成 50%"]
B --> D["慢路径 50%"]
D --> E["fuel 耗尽 trap"]
E --> F["重试"]
F --> G["仍 trap → 30s 超时"]
style E fill:#dc2626,color:#fff
调查过程:
- 监控显示 50% 请求 trap,错误信息为
all fuel consumed by WebAssembly(Wasmtime 的Trap::OutOfFuel,文案见crates/environ/src/trap_encoding.rs:100) - 查看 fuel 配置:
store.set_fuel(100_000)——大致相当于 10 万条 WASM 指令的预算 - 离线回放生产请求、用
Store::get_fuel前后相减:典型请求消耗约 50 万 fuel(静态分析给不出这个数,fuel 是按实际执行计的) - 部分输入触发深度递归,消耗远超平均
根因:fuel 配置基于"平均情况"——但生产输入有长尾,部分需要 5x 平均值。
修复:
// 修复前
store.set_fuel(100_000)?;
// 修复后:基于 P99 输入大小估算
const FUEL_PER_INPUT_BYTE: u64 = 100;
let estimated_fuel = (input.len() as u64 * FUEL_PER_INPUT_BYTE).max(500_000);
store.set_fuel(estimated_fuel)?;
教训:fuel 配置必须基于 P99 而非平均——否则长尾输入全部失败。
13.18.2 事故 2:WASI 实例池泄漏导致 OOM
症状:一个长时间运行的 WASI 服务,每天内存增长 5GB——一周后 OOM 重启。
症状特征:
sequenceDiagram
participant App as 业务代码
participant Pool as 实例池
participant Mem as 内存
App->>Pool: acquire instance
Pool->>Mem: 创建实例(200MB)
Pool-->>App: 返回
App->>App: 处理(20ms)
App->>Pool: release
Note over Pool: 应该归还,但泄漏!
App->>Pool: acquire instance(新建)
Note over Mem: 内存持续增长
调查过程:
- 监控显示内存稳定增长,每天约 5GB
- dhat profiler 显示数十万个
wasmtime::Store没有被释放(实例的内存归Store所有,wasmtime::Instance只是指向Store内部的一个可复制句柄) - 查看代码:错误路径上提前
return,跳过了手写的pool.release(...)
根因:错误路径下 release 没被调用——Store 没归还到池而是泄漏。
修复:用 RAII 模式自动释放:
// 池化的单位是 Store(它拥有实例的全部内存),Instance 只是它里面的句柄
struct PooledStore {
store: Option<wasmtime::Store<MyState>>,
pool: Arc<StorePool>,
}
impl Drop for PooledStore {
fn drop(&mut self) {
// 提前 return、? 传播错误、panic 展开时都会走到这里
if let Some(store) = self.store.take() {
self.pool.release(store);
}
}
}
教训:手动 release 模式必然有人忘——RAII / Drop trait 是更可靠的资源管理。更根本的是,把用过的 Store 放回池子给下一个请求用,本身就会让上一个请求留在线性内存里的数据被下一个请求看到;Wasmtime 推荐的复用方式是 InstancePre + 每请求新建 Store、内存槽交给 pooling allocator(第 12 章 §12.18.6)——每个 Store 随请求结束整个丢掉,也就不存在「忘了归还」这回事。
13.19 跨专栏关联:与 Hyper/Tower HTTP 栈的架构对比
WASI 的 wasi:http 接口和《Hyper 与 Tower:工业级 HTTP 栈》专栏第 2 章的 Service trait、第 3 章的 Layer 组合有相似的设计哲学:
- Hyper/Tower:
Service<Request>→Future<Output = Response>——请求-响应模型,异步处理。Tower 的 middleware 通过Layer组合——日志、认证、限流可以层层叠加 - WASI HTTP:
wasi:http/incoming-handler.handle(request, response-out)——也是请求-响应模型,但处理函数是同步签名的(异步通过wasi:io/poll实现)
关键架构差异:Tower 的 Service 是 Rust 类型系统的一部分——middleware 通过泛型和 trait bound 在编译时组合。WASI HTTP 的 handler 是组件模型的接口——middleware 通过组件链接在实例化时组合。前者是零开销抽象(编译时内联),后者是运行时组合(需要经过 Canonical ABI 的编解码步骤)。
这意味着:如果追求极致吞吐,用 Hyper + Tower 原生编译;如果追求安全隔离和可移植性,用 WASI HTTP。WASI 侧的额外开销有明确来源——每次请求都要过 Canonical ABI 的编解码、跨一次沙箱边界、走一遍 wasi:io/streams 的读写;具体折损多少取决于请求体大小和 handler 里做了多少 host 调用,必须自己压测,不要拿别人的倍数当结论。对多数应用来说这个代价换来的隔离性是划算的。
下一章看组件模型——WASM 的"可组合软件单元"梦想如何成为现实。