在受限环境中运行 Rust 会带来挑战。你的代码可能无法访问完整的操作系统,例如 Linux、Windows 或 macOS。你可能对文件、网络、时间、随机数甚至内存的访问权限有限(或根本没有)。我们将探索解决方法和解决方案。
本文的第一部分重点介绍在 “WASM WASI” 上运行代码,这是一种类似容器的环境。我们将看到 WASM WASI 本身可能(也可能不)有用。但是,它作为在浏览器或嵌入式系统中运行 Rust 的第一步很有价值。
将代码移植到 WASM WASI 上需要许多步骤和选择。浏览这些选择可能很耗时。错过一步会导致失败。我们将通过提供九条规则来减少这种复杂性,我们将在后面详细探讨:
规则 1:准备好失望:WASM WASI 很容易,但 - 现在 - 基本上没用 - 除了作为垫脚石。
2019 年,Docker 联合创始人 Solomon Hykes 发布了一条推文[1]:
如果 WASM+WASI 在 2008 年就存在,我们就无需创建 Docker。这就是它如此重要的原因。服务器上的 Webassembly 是计算的未来。标准化的系统接口是缺失的一环。让我们希望 WASI 能胜任这项任务。
如今,如果你关注科技新闻,你就会看到像这样的乐观标题:
如果 WASM WASI 真正准备就绪并有用,每个人都会使用它。我们不断看到这些标题的事实表明它还没有准备好。换句话说,如果 WASM WASI 真的准备好了,他们就不需要不断强调它已经准备好了。
截至 WASI 预览版 1,现状如下:你可以访问一些文件操作、环境变量,并可以访问时间和随机数生成。但是,不支持网络功能。
WASM WASI 可能 对某些 AWS Lambda 风格的 Web 服务有用,但即使那也还不确定。因为,与 WASM WASI 相比,你难道不更愿意将你的 Rust 代码本地编译并以一半的成本运行两倍的速度吗?
也许 WASM WASI 对插件和扩展有用。在基因组学领域,我有一个用于 Python 的 Rust 扩展,我为 25 种不同的组合编译它(5 个版本的 Python 跨 5 个操作系统目标)。即使这样,我也没有涵盖所有可能的操作系统和芯片系列。我能用 WASM WASI 替换这些操作系统目标吗?不能,它会太慢。我能将 WASM WASI 作为一个第六个“万能”目标添加进去吗?也许可以,但如果我真的需要可移植性,我已经被要求支持 Python,应该直接使用 Python。
那么,WASM WASI 到底有什么用?目前,它的主要价值在于它是将代码运行在浏览器或嵌入式系统中的第一步。
规则 2:了解 Rust 目标。
在规则 1 中,我顺便提到了“操作系统目标”。让我们更深入地了解 Rust 目标 - 这不仅对于 WASM WASI 来说是必要的信息,而且对于一般的 Rust 开发也是如此。
在我的 Windows 机器上,我可以编译一个 Rust 项目以在 Linux 或 macOS 上运行。类似地,从 Linux 机器上,我可以编译一个 Rust 项目以针对 Windows 或 macOS。以下是我用于将 Linux 目标添加到 Windows 机器并检查它的命令:
rustup target add x86_64-unknown-linux-gnu
cargo check --target x86_64-unknown-linux-gnu
旁白:虽然 cargo check 验证代码是否可以编译,但构建一个功能齐全的可执行文件需要额外的工具。要从 Windows 交叉编译到 Linux (GNU),你还需要安装 Linux GNU C/C++ 编译器和相应的工具链。这可能很棘手。幸运的是,对于我们关心的 WASM 目标,所需的工具链很容易安装。
要查看 Rust 支持的所有目标,请使用以下命令:
rustc --print target-list
它将列出超过 200 个目标,包括 x86_64-unknown-linux-gnu、wasm32-wasip1 和 wasm32-unknown-unknown。
目标名称包含最多四个部分:CPU 系列、供应商、操作系统和环境(例如,GNU 与 LVMM):
目标名称部分 - 来自作者的图片
现在我们对目标有所了解,让我们继续安装我们需要的 WASM WASI 目标。
规则 3:安装 wasm32-wasip1 目标和 WASMTIME,然后创建“Hello, WebAssembly!”。
要将我们的 Rust 代码在浏览器之外的 WASM 上运行,我们需要将目标设置为 wasm32-wasip1(使用 WASI 预览版 1 的 32 位 WebAssembly)。我们还将安装 WASMTIME,这是一个允许我们在浏览器之外使用 WASI 运行 WebAssembly 模块的运行时。
rustup target add wasm32-wasip1
cargo install wasmtime-cli
为了测试我们的设置,让我们使用 cargo new 创建一个新的“Hello, WebAssembly!” Rust 项目。这将初始化一个新的 Rust 包:
cargo new hello_wasi
cd hello_wasi
编辑 src/main.rs 使其内容如下:
fn main() {
#[cfg(not(target_arch = "wasm32"))]
println!("Hello, world!");
#[cfg(target_arch = "wasm32")]
println!("Hello, WebAssembly!");
}
旁白:我们将在规则 4 中更深入地了解 #[cfg(...)] 属性,该属性允许条件编译。
现在,使用 cargo run 运行项目,你应该看到 Hello, world! 打印到控制台上。
接下来,创建一个 .cargo/config.toml 文件,该文件指定 Rust 在针对 WASM WASI 时应该如何运行和测试项目。
[target.wasm32-wasip1]
runner = "wasmtime run --dir ."
旁白:这个 .cargo/config.toml 文件与主 Cargo.toml 文件不同,后者定义了你的项目的依赖项和元数据。
现在,如果你输入:
cargo run --target wasm32-wasip1
你应该看到 Hello, WebAssembly!。恭喜!你刚刚成功地在类似容器的 WASM WASI 环境中运行了一些 Rust 代码。
规则 4:了解条件编译。
现在,让我们研究一下 #[cfg(...)] - 这是在 Rust 中条件编译代码的重要工具。在规则 3 中,我们看到了:
fn main() {
#[cfg(not(target_arch = "wasm32"))]
println!("Hello, world!");
#[cfg(target_arch = "wasm32")]
println!("Hello, WebAssembly!");
}
#[cfg(...)] 行告诉 Rust 编译器根据特定条件包含或排除某些代码项。一个“代码项”指的是代码单元,例如函数、语句或表达式。
使用 #[cfg(…)] 行,你可以条件编译你的代码。换句话说,你可以为不同的情况创建代码的不同版本。例如,在为 wasm32 目标编译时,编译器会忽略 #[cfg(not(target_arch = "wasm32"))] 块,只包含以下内容:
fn main() {
println!("Hello, WebAssembly!");
}
你通过表达式指定条件,例如 target_arch = "wasm32"。支持的键包括 target_os 和 target_arch。有关支持的键的完整列表,请参阅 Rust 参考手册 完整列表[2]。你还可以使用 Cargo 功能创建表达式,我们将在规则 6 中学习。
你可以使用逻辑运算符 not、any 和 all 来组合表达式。Rust 的条件编译不使用传统的 if...then...else 语句。相反,你必须使用 #[cfg(...)] 及其否定来处理不同的情况:
#[cfg(not(target_arch = "wasm32"))]
...
#[cfg(target_arch = "wasm32")]
...
要条件编译整个文件,请将 #![cfg(...)] 放置在文件的顶部。(注意“!”)。当一个文件只与特定目标或配置相关时,这很有用。
你也可以在 Cargo.toml 中使用 cfg 表达式来条件包含依赖项。这允许你根据不同的目标定制依赖项。例如,这表示“当不针对 wasm32 时,依赖于具有 Rayon 的 Criterion”。
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
criterion = { version = "0.5.1", features = ["rayon"] }
规则 5:运行常规测试,但使用 WASM WASI 目标。
现在,让我们尝试在 WASM WASI 上运行 你的 项目。如规则 3 中所述,为你的项目创建一个 .cargo/config.toml 文件。它告诉 Cargo 如何在 WASM WASI 上运行和测试你的项目。
[target.wasm32-wasip1]
runner = "wasmtime run --dir ."
接下来,你的项目 - 就像所有好的代码一样 - 应该已经包含测试[3]。我的 range-set-blaze 项目包含以下示例测试:
#[test]
fn insert_255u8() {
let range_set_blaze = RangeSetBlaze::<u8>::from_iter([255]);
assert!(range_set_blaze.to_string() == "255..=255");
}
现在,让我们尝试在 WASM WASI 上运行你的项目的测试。使用以下命令:
cargo test --target wasm32-wasip1
如果这能正常工作,你可能就完成了 - 但它可能不会正常工作。当我在 range-set-blaze 上尝试这个命令时,我得到了一条错误消息,抱怨在 WASM 上使用 Rayon。
error: Rayon cannot be used when targeting wasi32. Try disabling default features.
--> C:\Users\carlk\.cargo\registry\src\index.crates.io-6f17d22bba15001f\criterion-0.5.1\src\lib.rs:31:1
|
31 | compile_error!("Rayon cannot be used when targeting wasi32. Try disabling default features.");
要修复此错误,我们首先需要了解 Cargo 功能。
规则 6:了解 Cargo 功能。
为了解决像规则 5 中的 Rayon 错误这样的问题,了解 Cargo 功能如何工作非常重要。
在 Cargo.toml 中,一个可选的 [features] 部分允许你根据启用的功能或禁用的功能来定义项目的不同配置或版本。例如,以下是 Criterion 基准测试项目 的 Cargo.toml 文件的简化部分:
[features]
default = ["rayon", "plotters", "cargo_bench_support"]
rayon = ["dep:rayon"]
plotters = ["dep:plotters"]
html_reports = []
cargo_bench_support = []
[dependencies]
#...
# 可选依赖项
rayon = { version = "1.3", optional = true }
plotters = { version = "^0.3.1", optional = true, default-features = false, features = [
"svg_backend",
"area_series",
"line_series",
] }
这定义了四个 Cargo 功能:rayon、plotters、html_reports 和 cargo_bench_support。由于每个功能都可以包含或排除,因此这四个功能创建了项目的 16 种可能的配置。还要注意特殊的默认 Cargo 功能。
一个 Cargo 功能可以包含其他 Cargo 功能。在上面的示例中,特殊的 default Cargo 功能包含了另外三个 Cargo 功能 - rayon、plotters 和 cargo_bench_support。
一个 Cargo 功能可以包含一个依赖项。上面的 rayon Cargo 功能包含 rayon 箱子作为依赖包。
此外,依赖包可能拥有自己的 Cargo 功能。例如,上面的 plotters Cargo 功能包含 plotters 依赖包,并启用了以下 Cargo 功能:svg_backend、area_series 和 line_series。
你可以在运行 cargo check、cargo build、cargo run 或 cargo test 时指定要启用或禁用的 Cargo 功能。例如,如果你正在使用 Criterion 项目并只想检查 html_reports 功能,而不使用任何默认功能,你可以运行:
cargo check --no-default-features --features html_reports
此命令告诉 Cargo 不要默认包含任何 Cargo 功能,而是专门启用 html_reports Cargo 功能。
在你的 Rust 代码中,你可以根据启用的 Cargo 功能包含/排除代码项。语法使用 #cfg(…),如规则 4 所示:
#[cfg(feature = "html_reports")]
SOME_CODE_ITEM
了解了 Cargo 功能之后,我们现在可以尝试修复在 WASM WASI 上运行测试时遇到的 Rayon 错误。
规则 7:更改你能更改的东西:通过选择 Cargo 功能解决依赖问题,64 位/32 位问题。
当我们尝试运行 cargo test --target wasm32-wasip1 时,错误消息的一部分指出:Criterion ... Rayon cannot be used when targeting wasi32. Try disabling default features. 这表明我们应该在针对 WASM WASI 时禁用 Criterion 的 rayon Cargo 功能。
为此,我们需要在 Cargo.toml 中进行两个更改。首先,我们需要在 [dev-dependencies] 部分禁用 Criterion 的 rayon 功能。因此,这个起始配置:
[dev-dependencies]
criterion = { version = "0.5.1", features = ["html_reports"] }
变成了这个,我们显式地关闭 Criterion 的默认功能,然后启用除 rayon 之外的所有 Cargo 功能。
[dev-dependencies]
criterion = { version = "0.5.1", features = [
"html_reports",
"plotters",
"cargo_bench_support"
],
default-features = false }
接下来,为了确保 rayon 仍然用于非 WASM 目标,我们在 Cargo.toml 中添加了一个条件依赖项,如下所示:
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
criterion = { version = "0.5.1", features = ["rayon"] }
一般来说,在针对 WASM WASI 时,你可能需要修改你的依赖项及其 Cargo 功能以确保兼容性。有时这个过程很简单,但有时它可能很困难 - 甚至不可能,正如我们将在规则 8 中讨论的那样。
旁白:在本系列的下一篇文章中 - 关于浏览器中的 WASM - 我们将更深入地探讨修复依赖项的策略。
再次运行测试后,我们越过了之前的错误,却遇到了一个新的错误,这是一种进步!
#[test]
fn test_demo_i32_len() {
assert_eq!(demo_i32_len(i32::MIN..=i32::MAX), u32::MAX as usize + 1);
^^^^^^^^^^^^^^^^^^^^^ attempt to compute
`usize::MAX + 1_usize`, which would overflow
}
编译器抱怨 u32::MAX as usize + 1 溢出了。在 64 位 Windows 上,该表达式不会溢出,因为 usize 与 u64 相同,并且可以容纳 u32::MAX as usize + 1。但是,WASM 是一个 32 位环境,因此 usize 与 u32 相同,该表达式大了一个。
这里的解决方法是用 u64 替换 usize,确保表达式不会溢出。更一般地说,编译器不会总是捕获这些问题,因此审查你对 usize 和 isize 的使用非常重要。如果你指的是 Rust 数据结构的大小或索引,usize 是正确的。但是,如果你处理的值超过了 32 位限制,你应该使用 u64 或 i64。
“
旁白:在 32 位环境中,Rust 数组、Vec、BTreeSet 等只能容纳最多 2³²−1=4,294,967,295 个元素。
因此,我们已经解决了依赖问题并解决了 usize 溢出问题。但是,我们能修复所有问题吗?不幸的是,答案是否定的。
规则 8:接受你无法更改所有东西:网络、Tokio、Rayon 等。
WASM WASI 预览版 1(当前版本)支持文件访问(在指定目录内)、读取环境变量以及处理时间和随机数。但是,与你可能从完整操作系统中期望的功能相比,它的功能有限。
如果你的项目需要访问网络、使用 Tokio 进行异步任务或使用 Rayon 进行多线程,不幸的是,这些功能在预览版 1 中不受支持。
幸运的是,WASM WASI 预览版 2 预计将改进这些限制,提供更多功能,包括对网络和可能异步任务的更好支持。
规则 9:将 WASM WASI 添加到你的 CI(持续集成)测试中。
因此,你的测试在 WASM WASI 上通过了,你的项目也成功运行了。你完成了?还没有。因为,正如我喜欢说的:
“
如果不在 CI 中,它就不存在。
持续集成 (CI) 是一个系统,它可以在你每次更新代码时自动运行你的测试,确保你的代码能够继续按预期工作。通过将 WASM WASI 添加到你的 CI 中,你可以保证未来的更改不会破坏你的项目与 WASM WASI 目标的兼容性。
在我的情况下,我的项目托管在 GitHub 上,我使用 GitHub Actions 作为我的 CI 系统。以下是我添加到 .github/workflows/ci.yml 中的配置,用于在我的项目上测试 WASM WASI:
test_wasip1:
name: Test WASI P1
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
targets: wasm32-wasip1
- name: Install Wasmtime
run: |
curl https://wasmtime.dev/install.sh -sSf | bash
echo "${HOME}/.wasmtime/bin" >> $GITHUB_PATH
- name: Run WASI tests
run: cargo test --verbose --target wasm32-wasip1
通过将 WASM WASI 集成到 CI 中,我可以放心地向我的项目添加新代码。CI 将自动测试所有代码在未来继续支持 WASM WASI。
因此,这就是将你的 Rust 代码移植到 WASM WASI 的九条规则。以下是我对移植到 WASM WASI 的感受:
不好之处:
- 在 WASM WASI 上运行在今天几乎没有实用价值。但是,它有潜力在明天变得有用。
- 在 Rust 中,有一句常见的说法:“如果它可以编译,它就可以工作。”不幸的是,这并不总是适用于 WASM WASI。如果你使用了不支持的功能,比如网络功能,编译器将不会捕获错误。相反,它将在运行时失败。例如,这段代码可以在 WASM WASI 上编译和运行,但始终返回错误,因为不支持网络功能。
use std::net::TcpStream;
fn main() {
match TcpStream::connect("crates.io:80") {
Ok(_) => println!("Successfully connected."),
Err(e) => println!("Failed to connect: {e}"),
}
}
好之处:
- 在 WASM WASI 上运行是将代码运行在浏览器和嵌入式系统中的一个很好的第一步。
- 你可以在 WASM WASI 上运行 Rust 代码,而无需移植到 no_std。(移植到 no_std 是本系列文章的第三部分的主题。)
- 你可以在 WASM WASI 上运行标准的 Rust 测试,这使得验证你的代码变得很容易。
- .cargo/config.toml 文件和 Rust 的 --target 选项使得在不同的目标上配置和运行你的代码变得非常简单 - 包括 WASM WASI。
参考资料
[1] 发布了一条推文: https://x.com/solomonstre/status/1111004913222324225
[2] 完整列表: https://doc.rust-lang.org/reference/conditional-compilation.html#set-configuration-options
[3] 你的项目 - 就像所有好的代码一样 - 应该已经包含测试: https://doc.rust-lang.org/rust-by-example/testing.html