Rust + WebAssembly 全链路解析

第13章 WASI Preview 2 与 Wasmtime 运行时

作者 杨艺韬 · 21,999 字 · 发布于 · 更新于

"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 做了这些事:

  1. 实例化 WASM 组件,配置 wasi:http 导入(加 -Scli 时再加完整的 wasi:cli 导入)
  2. 启动一个 hyper HTTP 服务器,监听指定端口
  3. 收到请求时,构造 incoming-request 资源,调用 WASM 的 handle 函数
  4. 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 已经展开,这里只列要点):

  1. 相对句柄解析:所有路径都相对预打开目录那个 fd 解析,模块拿不到绝对路径
  2. 解析期约束:Linux 5.6+ 用 openat2 的 RESOLVE_BENEATH、FreeBSD 13.0+ 用 openat(O_RESOLVE_BENEATH),由内核在一次系统调用里保证不越界;其余平台逐段打开路径分量,专门处理 .. 和符号链接。越界返回 PermissionDenied
  3. 权限检查:即使路径合法,也要检查操作是否符合预打开时声明的权限——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/proxy world 的接口,而 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>;
    }
}

工作流:

  1. load-by-name(宿主预注册的模型)或 load(自带权重字节)→ 得到 graph 资源
  2. init-execution-context() → graph-execution-context
  3. 把输入打包成 list<named-tensor> 一次性传给 compute()
  4. 从返回的 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 模块通过名字引用,不需要持有模型字节。这种"模型在宿主、计算分离"的架构有几个优势:

  1. 多个 WASM 实例共享同一个模型:模型只加载一次,节省内存
  2. WASM 模块体积小:模型不在 WASM 内
  3. 宿主可以优化:选择最优 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

调查过程:

  1. 监控显示 50% 请求 trap,错误信息为 all fuel consumed by WebAssembly(Wasmtime 的 Trap::OutOfFuel,文案见 crates/environ/src/trap_encoding.rs:100)
  2. 查看 fuel 配置:store.set_fuel(100_000)——大致相当于 10 万条 WASM 指令的预算
  3. 离线回放生产请求、用 Store::get_fuel 前后相减:典型请求消耗约 50 万 fuel(静态分析给不出这个数,fuel 是按实际执行计的)
  4. 部分输入触发深度递归,消耗远超平均

根因: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: 内存持续增长

调查过程:

  1. 监控显示内存稳定增长,每天约 5GB
  2. dhat profiler 显示数十万个 wasmtime::Store 没有被释放(实例的内存归 Store 所有,wasmtime::Instance 只是指向 Store 内部的一个可复制句柄)
  3. 查看代码:错误路径上提前 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 的"可组合软件单元"梦想如何成为现实。