rust入坑小记-13-测试与异步
12 编写自动化测试
软件测试是证明bug存在的有效方法,但却无法证明错误不存在。
Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.
Edsger W. Dijkstra, “The Humble Programmer” 1972
程序的正确性意味着代码如我们期望的那样运行。rust是一个相当注重正确性的编程语言,不过正确性是一个难以证明的复杂主题。rust的类型系统在此问题上下了很大的功夫,不过类型系统不可能捕获所有问题。为此,rust包含了编写自动化软件测试的功能支持。
假设我们可以编写一个叫做 add_two 的将传递给它的值加二的函数。它的签名有一个整型参数并返回一个整型值。当实现和编译这个函数时,rust会进行所有目前我们已经见过的类型检查和借用检查,例如,这些检查会确保我们不会传递 String 或无效的引用给这个函数。rust所不能检查的是这个函数是否会准确的完成我们期望的工作:返回参数加二后的值,而不是比如说参数加 10 或减 50 的值,换句话说,rust可以检查语法问题,但无法检测正确语法下的代码逻辑问题。这也就是测试出场的地方。
我们可以编写测试断言,比如说,当传递 3 给 add_two 函数时,返回值是 5。无论何时对代码进行修改,都可以运行测试来确保任何现存的正确行为没有被改变。
12.1 如何编写测试
rust中的测试函数是用来验证非测试代码是否是按照期望的方式运行的。测试函数体通常执行如下三种操作:
- 设置任何所需的数据或状态
- 运行需要测试的代码
- 断言其结果是我们所期望的
让我们看看rust提供的专门用来编写测试的功能:test 属性、一些宏和 should_panic 属性。
测试函数
作为最简单例子,rust中的测试就是一个带有 test 属性注解的函数。属性(attribute)是关于rust代码片段的元数据,之前出现过的 derive 属性就是一个例子,使用它可以派生自动实现的 Debug 、Copy 等特征,同样的,使用 test 属性,我们也可以获取rust提供的测试特性。
为了将一个函数变成测试函数,需要在 fn 行之前加上 #[test]。当使用 cargo test 命令运行测试时,rust会构建一个测试执行程序用来调用被标注的函数,并报告每一个测试是通过还是失败。
每次使用cargo新建一个库项目时,它会自动为我们生成一个测试模块和一个测试函数。这个模块提供了一个编写测试的模板,为此每次开始新项目时不必去查找测试函数的具体结构和语法了。因为这样当然你也可以额外增加任意多的测试函数以及测试模块。在实际编写测试代码之前,让我们先通过尝试那些自动生成的测试模版来探索测试是如何工作的。接着,我们会写一些真正的测试,调用我们编写的代码并断言他们的行为的正确性。
让我们创建一个新的库项目 adder,它会将两个数字相加:
1 | $ cargo new adder --lib |
自动生成的文件src/lib.rs中的内容应该是这样的:
1 | pub fn add(left: usize, right: usize) -> usize { |
现在让我们暂时忽略 tests 模块和 #[cfg(test)] 注解并只关注函数本身。注意 fn 行之前的 #[test],这个属性表明这是一个测试函数,这样测试执行者就知道将其作为测试处理。tests 模块中也可以有非测试的函数来帮助我们建立通用场景或进行常见操作,必须每次都标明哪些函数是测试。
注意在 tests 模块中有一行:use super::*;。tests 是一个普通的模块,它遵循模块的可见性规则。因为这是一个内部模块,要测试外部模块中的代码,需要将其引入到内部模块的作用域中(见:使用super起始的相对路径)。这里选择使用全局导入,以便在 tests 模块中使用所有在外部模块定义的内容。
示例函数体通过使用 assert_eq! 宏来断言 2 加 2 等于 4。一个典型的测试的格式,就是像这个例子中的断言一样。接下来运行就可以看到测试通过。
我们通过cargo test命令运行项目中所有的测试:
1 | running 1 test |
cargo编译并运行了测试。可以看到 running 1 test 这一行。下一行显示了生成的测试函数的名称,它是 it_works,以及测试的运行结果,ok。接着可以看到全体测试运行结果的摘要:test result: ok. 意味着所有测试都通过了。1 passed; 0 failed 表示通过或失败的测试数量。
可以将一个测试标记为忽略(ignore)这样在特定情况下它就不会运行,因为之前我们并没有将任何测试标记为忽略,所以摘要中会显示 0 ignored。我们也没有过滤需要运行的测试,所以摘要中会显示0 filtered out。0 measured 统计是针对性能测试的。见:忽略某些测试。
测试输出中的以 Doc-tests adder 开头的这一部分是所有文档测试的结果。我们现在并没有任何文档测试,不过rust会编译任何在 API 文档中的代码示例。这个功能帮助我们使文档和代码保持同步。
现在我们将忽略 Doc-tests 部分的输出,在后面会介绍,见:文档测试。
让我们开始自定义测试来满足我们的需求。首先给添加一个新的测试,比如 exploration,像这样:
1 | pub fn add(left: usize, right: usize) -> usize { |
这个exploration测试会报错,因为显然2+2不等于5。再次 cargo test 运行测试:
1 | running 2 tests |
它表明 it_works 测试通过了而 exploration 失败了。在单独测试结果和摘要之间多了两个新的部分:第一个部分显示了测试失败的详细原因。在这个例子中,我们看到 exploration 因为在src/lib.rs的第 10 行未能通过断言而失败的详细信息。下一部分列出了所有失败的测试,这在有很多测试和很多失败测试的详细输出时很有帮助。我们将来可以通过使用失败测试的名称来只运行这个测试,以便调试。
最后是摘要行:总体上讲,测试结果是 FAILED。有一个测试通过和一个测试失败。
使用assert!宏来检查结果
assert! 宏由标准库提供,在希望确保测试中一些条件为 true 时非常有用。需要向 assert! 宏提供一个求值为布尔值的参数。如果值是 true,assert! 什么也不做,同时测试会通过。如果值为 false,assert! 调用 panic! 宏,这会导致测试失败。assert! 宏帮助我们检查代码是否以期望的方式运行。
比如,我们可以编写如下测试:
1 | pub fn greater_than_two(num: usize) -> bool { |
很明显,我们这个测试是希望验证greater_than_two的功能:判断num是否大于2。这里创建了两个测试分别传入3和2进行测试,运行测试的结果如下:
1 | running 2 tests |
一个成功一个失败了,这符合我们的预期。
使用assert_eq!和assert_ne!宏来测试相等
测试功能的一个常用方法是将需要测试代码的值与期望值做比较,并检查是否相等。可以通过向 assert! 宏传递一个使用 == 运算符的表达式来做到。不过这个操作实在是太常见了,以至于标准库提供了一对宏来更方便的处理这些操作 —— assert_eq! 和 assert_ne!。这两个宏分别比较两个值是相等还是不相等。当断言失败时他们也会打印出这两个值具体是什么,以便于观察测试为什么失败,而 assert! 只会打印出它从 == 表达式中得到了 false 值,而不是打印导致 false 的两个值。
我们已经见识过了assert_eq!,现在来看看assert_ne!的使用场景。assert_ne! 宏在传递给它的两个值不相等时通过,而在相等时失败。在代码按预期运行,我们不确定值会是什么,不过能确定值绝对不会是什么的时候,这个宏最有用处。例如,如果一个函数保证会以某种方式改变其输出,不过这种改变方式是由运行测试时是星期几来决定的,这时最好的断言可能就是函数的输出不等于其输入。
assert_eq! 和 assert_ne! 宏在底层分别使用了 == 和 !=。当断言失败时,这些宏会使用调试格式打印出其参数,这意味着被比较的值必需实现了 PartialEq 和 Debug 特征。所有的基本类型和大部分标准库类型都实现了这些特征。对于自定义的结构体和枚举,需要实现 PartialEq 才能断言它们的值是否相等。需要实现 Debug 才能在断言失败时打印它们的值。因为这两个特征都是派生特征,通常可以直接在结构体或枚举上添加 #[derive(PartialEq, Debug)] 注解。
自定义失败信息
你也可以向 assert!、assert_eq! 和 assert_ne! 宏传递一个可选的失败信息参数,可以在测试失败时将自定义失败信息一同打印出来。任何在 assert! 的一个必需参数和 assert_eq! 和 assert_ne! 的两个必需参数之后指定的参数都会传递给 format! 宏,传递一个包含 {} 占位符的格式字符串和需要放入占位符的值。自定义信息有助于记录断言的意义;当测试失败时就能更好的理解代码出了什么问题。
比如说有一个根据人名进行问候的函数,而我们希望测试将传递给函数的人名显示在输出中:
1 | pub fn greeting(name: &str) -> String { |
运行测试,报错仅仅告诉了我们断言失败了和失败的行号,并没有更多的信息。一个更有用的失败信息应该打印出 greeting 函数的值。让我们为测试函数增加一个自定义失败信息参数:带占位符的格式字符串,以及 greeting 函数的值:
1 |
|
这里assert!的第二个和第三个参数会传递给format!:
1 | "Greeting did not contain name, value was `{}`", |
运行测试,将会看到更有价值的信息:
1 | running 1 test |
可以在测试输出中看到所取得的确切的值,这会帮助我们理解真正发生了什么,而不是期望发生什么,并且详细的报错信息将帮助我们更好地进行debug。
使用should_panic检查 panic
除了检查返回值之外,检查代码是否按照期望处理错误也是很重要的,在某些情况下,我们可能想测试某个函数是否会panic。对此, rust提供了 should_panic 属性注解。这个属性在函数中的代码 panic 时会通过,而在其中的代码没有 panic 时失败。
1 | pub struct Guess { |
#[should_panic] 属性位于 #[test] 之后,对应的测试函数之前。这是一个简单的猜数字游戏,Guess 结构体的 new 方法在传入的值不在 [1,100] 之间时,会直接 panic,而在测试函数 greater_than_100 中,我们传入的值 200 显然没有落入该区间,程序应该panic。运行测试:
1 | running 1 test |
符合预期,测试通过。现在在代码中引入 bug,移除 new 函数在值大于 100 时会 panic 的条件:
1 | pub fn new(value: i32) -> Guess { |
再次运行测试:
1 | running 1 test |
测试失败了,开发者去检查这个测试时,会发现它标注了 #[should_panic]。这个错误意味着代码中测试函数 Guess::new(200) 并没有产生 panic,进一步可以定位到出错的函数进行debug。
然而 should_panic 测试结果可能会非常含糊不清。should_panic 甚至在一些不是我们期望的原因而导致 panic 时也会通过。比如这段糟糕的代码:
1 | pub fn new(value: i32) -> Guess { |
这里 new 函数根据其值是过大还或者过小而提供不同的 panic 信息,这会导致测试的结果模糊不清(到底是哪里造成了panic?)。为了使 should_panic 测试结果更精确,我们可以给 should_panic 属性增加一个可选的 expected 参数。测试工具会确保错误信息中包含其提供的文本。
1 | pub struct Guess { |
这个测试会通过,因为 should_panic 属性中 expected 参数提供的值是 Guess::new 函数 panic 信息的子串。我们可以指定期望的整个 panic 信息,在这个例子中是 Guess value must be less than or equal to 100, got 200. 。 expected 信息的选择取决于 panic 信息有多独特或动态,和你希望测试有多准确。在这个例子中,错误信息的子字符串足以确保函数在 else if value > 100 的情况下运行。
为了观察带有 expected 信息的 should_panic 测试失败时会发生什么,让我们再次引入一个 bug,将 if value < 1 和 else if value > 100 的代码块对换:
1 | if value < 1 { |
这一次运行 should_panic 测试,它会失败:
1 | running 1 test |
失败信息表明测试确实如期望 panic 了,不过 panic 信息中并没有包含 expected 信息 'Guess value must be less than or equal to 100'。而我们得到的 panic 信息是 'Guess value must be greater than or equal to 1, got 200.'。
将Result<T, E>用于测试
目前为止,我们编写的测试在失败时都会 panic,我们也可以使用 Result<T, E> 编写测试。比如:
1 |
|
现在 it_works 函数的返回值类型为 Result<(), String>。在函数体中,不同于调用 assert_eq! 宏,而是在测试通过时返回 Ok(()),在测试失败时返回带有 String 的 Err。
这样编写测试来返回 Result<T, E> 就可以在函数体中使用问号运算符?,如此可以方便地编写任何运算符会返回 Err 成员的测试。
不能对这些使用 Result<T, E> 的测试使用 #[should_panic] 注解。为了断言一个操作返回 Err 成员,不要使用对 Result<T, E> 值使用问号表达式(?)。而是使用 assert!(value.is_err())。
12.2 控制测试如何运行
就像 cargo run 会编译代码并运行生成的二进制文件一样,cargo test 在测试模式下编译代码并运行生成的测试二进制文件。cargo test 产生的二进制文件的默认行为是并发运行所有的测试,并截获测试运行过程中产生的输出,阻止他们被显示出来,使得阅读测试结果相关的内容变得更容易。不过可以指定命令行参数来改变 cargo test 的默认行为。
使用--分割命令行参数
可以将一部分命令行参数传递给 cargo test,而将另外一部分传递给生成的测试二进制文件。为了分隔这两种参数,需要先列出传递给 cargo test 的参数,接着是分隔符 --,再之后是传递给测试二进制文件的参数。运行 cargo test --help 会提示 cargo test 的有关参数,而运行 cargo test -- --help 可以提示在分隔符之后使用的有关参数。
测试用例的并行或顺序执行
当运行多个测试时,rust默认使用线程来并行运行。这意味着测试会更快地运行完毕,所以你可以更快的得到代码能否工作的反馈。因为测试是在同时运行的,你应该确保测试不能相互依赖,或依赖任何共享的状态,包括依赖共享的环境,比如当前工作目录或者环境变量。
举个例子,每一个测试都运行一些代码,假设这些代码都在硬盘上创建一个test-output.txt文件并写入一些数据。接着每一个测试都读取文件中的数据并断言这个文件包含特定的值,而这个值在每个测试中都是不同的。因为所有测试都是同时运行的,一个测试可能会在另一个测试读写文件过程中修改了文件。那么第二个测试就会失败,并不是因为代码不正确,而是因为测试并行运行时相互干扰。一个解决方案是使每一个测试读写不同的文件;另一个解决方案是一次运行一个测试。
如果你不希望测试并行运行,或者想要更加精确的控制线程的数量,可以传递 --test-threads 参数和希望使用线程的数量给测试二进制文件。例如:
1 | cargo test -- --test-threads=1 |
这里的命令用--分隔开了,第二个参数用于传递给测试二进制文件,这里将测试线程设置为 1,告诉程序不要使用任何并行机制。这也会比并行运行花费更多时间,不过在有共享的状态时,测试就不会有潜在的相互干扰了。
显示函数输出
默认情况下,当测试通过时,rust的测试库会截获打印到标准输出的所有内容。比如在测试中调用了 println! 而测试通过了,我们将不会在终端看到 println! 的输出:只会看到说明测试通过的提示行。如果测试失败了,则会看到所有标准输出和其他错误信息。
例如这里有一个无意义的函数,它打印出其参数的值并接着返回10。接着还有一个会通过的测试和一个会失败的测试:
1 | fn prints_and_returns_10(a: i32) -> i32 { |
运行测试的输出:
1 | running 2 tests |
注意输出中不会出现测试通过时打印的内容,即 I got the value 4。因为当测试通过时,这些输出会被截获。失败测试的输出 I got the value 8 ,则出现在输出的测试摘要部分,同时也显示了测试失败的原因。
如果你希望也能看到通过的测试中打印的值,也可以在结尾加上 --show-output 告诉rust显示成功测试的输出。
1 | cargo test -- --show-output |
这次就可以看到成功测试的输出结果了:
1 | running 2 tests |
通过指定名字来运行部分测试
有时运行整个测试集会耗费很长时间。如果你负责特定位置的代码,你可能会希望只运行与这些代码相关的测试。你可以向 cargo test 传递所希望运行的测试名称的参数来选择运行哪些测试。
首先创建三个测试:
1 | pub fn add_two(a: i32) -> i32 { |
如果没有传递任何参数就运行测试,所有测试都会并行运行。可以向 cargo test 传递任意测试的名称来只运行这个测试,比如:
1 | cargo test one_hundred |
将只有名为one_hundred的测试被运行:
1 | running 1 test |
因为其余两个测试并不匹配这个名称。测试输出在摘要行的结尾显示了 2 filtered out 表明还存在比本次所运行的测试更多的测试没有被运行。
这种方式只能指定单个测试名称,如果想要指定两个名称,下面的方法是行不通的:
1 | cargo test one_hundred add_two_and_two |
我们可以通过指定部分名称的方式来过滤运行相应的测试。因为有两个测试的名称包含 add,可以指定这两个测试名称中共同包含的部分来运行这两个测试:
1 | cargo test add |
测试结果为:
1 | running 2 tests |
这运行了所有名字中带有 add 的测试,也过滤掉了名为 one_hundred 的测试。同时注意测试所在的模块也是测试名称的一部分,所以可以通过模块名来运行一个模块中的所有测试。比如:
1 | cargo test tests |
这会运行模块名为tests的所有测试:
1 | running 3 tests |
忽略某些测试
有时一些特定的测试执行起来是非常耗费时间的,所以在大多数运行 cargo test 的时候希望能排除他们。虽然可以通过参数列举出所有希望运行的测试来做到,也可以使用 ignore 属性来标记耗时的测试并排除他们,如下所示:
1 |
|
对于想要排除的测试,我们在 #[test] 之后增加了 #[ignore] 行。现在如果运行测试,就会发现 it_works 运行了,而 expensive_test 没有运行:
1 | running 2 tests |
expensive_test 被列为 ignored,如果我们只希望运行被忽略的测试,可以使用:
1 | cargo test -- --ignored |
如果你希望不管是否忽略都要运行全部测试,可以运行:
1 | cargo test -- --include-ignored |
组合过滤运行测试
我们可以组合使用过滤方法,比如有这样的测试:
1 |
|
运行名称为 tests 的模块中的被忽略的测试函数:
1 | cargo test tests -- --ignored |
运行名称中带 run 且被忽略的测试函数:
1 | cargo test run -- --ignored |
通过组合使用这些命令可以更加灵活地运行部分测试。
开发者依赖[dev-dependencies]
如果你了解前端开发,这与package.json文件中的devdependencies一样,Cargo.toml文件中的[dev-dependencies]也是用于开发测试场景使用的依赖,这些依赖往往只在开发时使用而不需要为用户编译。
其中一个例子就是 pretty_assertions,它可以用来扩展标准库中的 assert_eq! 和 assert_ne!,例如提供彩色字体的结果对比。
在 Cargo.toml 文件中添加以下内容来引入 pretty_assertions:
1 | # standard crate data is left out |
然后在 src/lib.rs 文件中添加:
1 | pub fn add(a: i32, b: i32) -> i32 { |
在 tests 模块中,我们通过 use pretty_assertions::assert_eq; 成功的引入之前添加的包,由于 tests 模块明确的用于测试目的,这种引入并不会报错。 如果在正常代码(非测试代码)中引入该包则不行。
生成测试二进制文件
在有些时候,我们可能希望将测试与别人分享,这种情况下生成一个类似 cargo build 的可执行二进制文件是很好的选择。
事实上,在 cargo test 运行的时候,系统会自动为我们生成一个可运行测试的二进制可执行文件:
1 | cargo test |
这里的 target/debug/deps/study_cargo-0d693f72a0f49166 就是可执行文件的路径和名称,我们直接运行该文件来执行编译好的测试:
1 | target/debug/deps/study_cargo-0d693f72a0f49166 |
如果你只想生成编译生成文件,不需要 cargo test 的输出结果,可以使用 cargo test --no-run。
12.3 测试的组织结构
测试是一个复杂的概念,而且不同的开发者也采用不同的技术和组织。rust社区倾向于根据测试的两个主要分类来考虑问题:单元测试(unit tests)与集成测试(integration tests)。单元测试倾向于更小而更集中,在隔离的环境中一次测试一个模块,或者是测试私有接口。而集成测试对于你的库来说则完全是外部的。通常在单元测试的基础上,将所有的程序模块进行有序的、递增的测试。它们与其他外部代码一样,通过相同的方式使用你的代码,只测试公有接口而且每个测试都有可能会测试多个模块。
为了保证你的库能够按照你的预期运行,从独立和整体的角度编写这两类测试都是非常重要的。
单元测试
单元测试的目的是在与其他部分隔离的环境中测试每一个单元的代码,以便于快速而准确地验证某个单元的代码功能是否符合预期。单元测试与它们要测试的代码共同存放在相同的文件中。规范是在每个文件中创建包含测试函数的 tests 模块,并使用 cfg(test) 标注模块。
例如在src/lib.rs中的如下代码:
1 | pub fn add_two(a: i32) -> i32 { |
add_two 是我们的项目代码,为了对它进行测试,我们在同一个文件中编写了测试模块 tests,并使用 #[cfg(test)] 进行了标注。
测试模块和#[cfg(test)]
测试模块的 #[cfg(test)] 注解告诉rust只在执行 cargo test 时才编译和运行测试代码,而在运行 cargo build 时不这么做。这在只希望构建库的时候可以节省编译时间,并且因为它们并没有包含测试,所以能减少编译产生的文件的大小。与之对应的集成测试因为位于另一个文件夹,所以它们并不需要 #[cfg(test)] 注解。然而单元测试位于与源码相同的文件中,所以你需要使用 #[cfg(test)] 来指定它们不应该被包含进编译结果中。
cfg 属性代表configuration ,它告诉rust其之后的项只应该被包含进特定配置选项中。在这个例子中,配置选项是 test,即rust所提供的用于编译和运行测试的配置选项。通过使用 cfg 属性,cargo只会在我们主动使用 cargo test 运行测试时才编译测试代码。这包括测试模块中可能存在的帮助函数,以及标注为#[test]的函数。
测试私有函数
测试社区中一直存在关于是否应该对私有函数直接进行测试的论战,而在其他语言中想要测试私有函数是一件困难的,甚至是不可能的事。不过无论你坚持哪种测试意识形态,rust的私有性规则确实允许你测试私有函数。
文件名:src/lib.rs
1 | pub fn add_two(a: i32) -> i32 { |
internal_adder 并没有使用 pub 进行声明,因此它是一个私有函数。tests 作为另一个模块,是无法对它进行调用的,因为它们根本不在同一个模块中。在测试中,我们通过 use super::* 将 test 模块的父模块的所有项引入了作用域,接着测试调用了 internal_adder。如果你并不认为应该测试私有函数,rust也不会强迫你这么做。
集成测试
在rust中,集成测试对于你需要测试的库来说完全是外部的。同其它使用库的代码一样使用库文件,也就是说它们只能调用一部分库中的公有 API。集成测试的目的是测试库的多个部分能否一起正常工作。一些单独能正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。为了创建集成测试,你需要先创建一个tests目录。
tests目录
在典型的package结构中介绍过tests目录用于集成测试文件,在这个目录中可以创建任意多的测试文件,cargo会将每一个文件当作单独的crate来编译。
现在来创建一个集成测试。保留上一节src/lib.rs文件中的内容,然后创建一个tests目录,新建一个文件 tests/integration_test.rs。整体的目录结构应该看起来像这样:
1 | adder |
tests/integration_test.rs中的内容如下:
1 | use adder; |
因为每一个 tests 目录中的测试文件都是完全独立的crate,所以需要在每一个文件中导入库。为此与单元测试不同,我们需要在文件顶部添加use adder。
并不需要将tests/integration_test.rs中的任何代码标注为 #[cfg(test)]。 tests 文件夹在cargo中是一个特殊的文件夹,cargo只会在运行 cargo test 时编译这个目录中的文件。现在就运行 cargo test 试试:
1 | running 1 test |
现在有了三个部分的输出:单元测试、集成测试和文档测试。注意如果一个部分的任何测试失败,之后的部分都不会运行。例如如果一个单元测试失败,则不会有任何集成测试和文档测试的输出,因为这些测试只会在所有单元测试都通过后才会执行。
其中:第一部分单元测试与我们之前见过的一样:每个单元测试一行,接着是一个单元测试的摘要行。
集成测试部分以行 Running tests/integration_test.rs开头。接下来每一行是一个集成测试中的测试函数,以及一个位于 Doc-tests adder 部分之前的集成测试的摘要行。
每一个集成测试文件有对应的测试结果部分,所以如果在tests目录中增加更多文件,测试结果中就会有更多集成测试结果部分。
我们仍然可以通过指定测试函数的名称作为 cargo test 的参数来运行特定集成测试。也可以使用 cargo test 的 --test 后跟文件的名称来运行某个特定集成测试文件中的所有测试:
1 | $ cargo test --test integration_test |
这个命令只运行了tests目录中我们指定的文件 integration_test.rs 中的测试。
集成测试中的子模块
随着集成测试的增加,你可能希望在 tests 目录增加更多文件以便更好的组织它们,例如根据测试的功能来将测试分组。正如之前提到的,每一个 tests 目录中的文件都被编译为单独的crate。
将每个集成测试文件当作其自己的crate来对待,这更有助于创建单独的作用域,这种单独的作用域能提供更类似与最终使用者使用crate的环境,但是这样也无法使文件共享相同的行为。
当你有一些在多个集成测试文件都会用到的帮助函数,而你尝试将它们提取到一个通用的模块中时, tests 目录中不同文件的行为就会显得很明显。例如,如果我们可以创建一个 tests/common.rs 文件,在其中创建一个名叫 setup 的函数,我们希望这个函数能被多个测试文件的测试函数调用:
1 | pub fn setup() { |
如果再次运行测试,将会在测试结果中看到一个新的对应common.rs文件的测试结果部分,即便这个文件并没有包含任何测试函数,也没有任何地方调用了 setup 函数:
1 | running 1 test |
我们并不想要common 出现在测试结果中显示 running 0 tests 。我们只是希望其能被其它多个集成测试文件中调用罢了。
为了不让 common 出现在测试输出中,我们将创建tests/common/mod.rs,而不是创建tests/common.rs 。现在项目目录结构看起来像这样:
1 | ├── Cargo.lock |
这是一种旧的命名规范,正如代码组织总结中提到的,rust仍然支持它们。这样命名告诉rust不要将 common 看作一个集成测试文件。将 setup 函数代码移动到tests/common/mod.rs并删除tests/common.rs文件之后,测试输出中将不会出现这一部分。tests目录中的子目录不会被作为单独的crate编译或作为一个测试结果部分出现在测试输出中。
一旦拥有了tests/common/mod.rs,就可以将其作为模块以便在任何集成测试文件中使用。这里是一个 tests/integration_test.rs中调用 setup 函数的 it_adds_two 测试的例子:
1 | use adder; |
这里使用 mod common;声明了子模块,在代码组织总结中提到过,这是一种旧版风格。接着在测试函数中就可以调用 common::setup() 了。
二进制crate的集成测试
如果项目是二进制crate并且只包含src/main.rs而没有src/lib.rs,这样就不可能在tests目录创建集成测试并使用 extern crate 导入src/main.rs中定义的函数。只有库crate才会向其他crate暴露了可供调用和使用的函数;二进制 crate只意在单独运行。
这就是许多rust二进制项目使用一个简单的src/main.rs调用src/lib.rs中的逻辑的原因之一。因为通过这种结构,集成测试就可以通过 extern crate 测试库crate中的主要功能了,而如果这些重要的功能没有问题的话,src/main.rs中的少量代码也就会正常工作且不需要测试。
12.4 文档测试
在文档注释中增加示例代码块是一个清楚的表明如何使用库的方法,这么做还有一个额外的好处:cargo test 也会像测试那样运行文档中的示例代码。没有什么比有例子的文档更好的了,但最糟糕的莫过于写完文档后改动了代码,而导致例子不能正常工作。
文件名:src/lib.rs
1 | /// Adds one to the number given. |
以上的注释不仅仅是文档,还可以作为单元测试的用例运行,使用 cargo test 运行测试,结果如下:
1 | running 0 tests |
可以看到,文档中的测试用例被完美运行,而且输出中也明确提示了 Doc-tests adder,意味着这些测试的名字叫 Doc test 文档测试。尝试改变函数或例子来使例子中的 assert_eq! 产生 panic。再次运行 cargo test,你将会看到文档测试捕获到了例子与代码不再同步,这时就需要我们去debug了。
13 深入所有权和借用
1 堆和栈
栈空间
rust到目前为止还没有明确它所使用的内存布局,相关工作还在进行中,但可以明确的是堆和栈一定存在,这是操作系统中的概念。堆和栈都是内存中的空间,但是rust使用它们的方式不一样。
顾名思义,栈空间是一个后进先出的内存段,一般来说,每个函数都有一个栈帧,调用函数时,将在栈中创建一个栈帧,用来保存该函数的上下文数据。一般情况下会有一个专门的寄存器(SP)跟踪栈的顶部,当调用函数创建新的栈帧时,这个寄存器的值更新为此栈帧的地址,当函数返回且返回值已被读取后,该函数栈帧被移除出栈,出栈的方式很简单,只需更新SP寄存器使其指向上一个栈帧的地址即可。
不仅栈空间中的栈帧是后进先出的,栈帧内部的数据也是后进先出的。比如函数内先创建的局部变量在栈帧的底部,后创建的局部变量在栈帧的顶部。当然,上下顺序并非一定会如此,这和编译器有关,但编写程序时可如此理解。
实际上,有一个BP寄存器专门用来跟踪调用者栈帧的位置。当在函数a中调用函数b时,首先创建函数a的栈帧,当开始调用函数b时,将在栈顶创建函数b的栈帧,并拷贝上一个SP的值到BP,这样BP寄存器就保存了函数a的栈帧地址,当函数b返回时通过BP就可以回到函数a的栈帧。
栈空间的数据一般由编译器自动分配释放,这就需要在编译期知道变量的大小。在编写rust程序时,不用刻意去思考操作系统的栈空间和栈帧的概念,只需要与堆区分开即可。
堆空间
堆空间用于在运行时分配内存。堆空间是一片无人管理的自由内存区,需要时要手动申请,不需要时要手动释放,如果不释放已经无用的堆内存,将导致内存泄漏,内存泄漏过多可能会耗尽内存。
rust不需要手动管理内存的申请与释放,它通过所有权机制来完成内存管理。
2 rust使用堆和栈的方式
有些数据适合存放于堆,有些数据适合存放于栈。
栈适合存放存活时间短的数据
函数内部的局部变量适合存放在栈中,因为函数返回后,该函数中声明的局部变量就没有意义了,随着函数栈帧的释放,该栈中的所有数据也随之消失。
与之对应的,存活时间长的数据通常应该存放在堆空间中。比如多个函数(有不同栈帧)共用的数据应该存放在堆中,这样即使一个函数返回也不会销毁这份数据。
存放在栈中的数据大小必须已知
只有这样,编译器才知道在栈中为该数据分配多少内存。与之对应的,如果无法在编译期间得知数据类型的大小,该数据将不允许存放在栈中,只能存放在堆中。
例如,i32类型的数据存放在栈中,因为i32类型的大小是固定的,无论对它做什么操作,只要它仍然是i32类型,那么它的大小就一定是4字节。而String类型的数据是存放在堆中的,因为String类型的字符串是可变而非固定大小的,最初初始化的时候可能是空字符串,但可以在后期向此空字符串中加入任意长度的字符串,编译器显然无法在编译期间就得知字符串的长度。
使用栈的效率要高于使用堆
将数据存放于栈中时,因为编译器已经知道将要存放于栈中数据的大小,所以编译器总是在栈帧中分配合适大小的内存来存放数据。另一方面,栈中数据的存放方式是后进先出。这相当于编译器总是找好各种大小合适的内存块来存放数据并将内存块放在栈的顶部,而释放栈中数据的方式则是从栈顶拿走这部分内存块的数据。
但是将数据存放于堆中时,当程序运行时会向操作系统申请一片空闲的堆内存空间,然后将数据存放进去。由于堆内存空间是无人管理的自由内存区,操作系统想要从堆中找到空闲空间需要做一些额外操作。更严重的是堆中有大量碎片内存的情况,操作系统可能会将多份小的碎片空闲内存通过链表的方式连接起来组成一个大的空闲空间分配给程序,这样的效率是不及栈的。
rust存放于栈中/堆中的数据
rust中各种类型的值默认都存储在栈中,除非显式地使用Box::new()将它们存放在堆上。但数据要存放在栈中,要求其数据类型的大小已知。对于静态大小的类型,可直接存储在栈上。
例如如下类型的数据存放在栈中:
- 裸指针(一个机器字长)、普通引用(一个机器字长)、胖指针(除了指针外还包含其他元数据信息,智能指针也是一种带有额外功能的胖指针,而胖指针实际上又是
Struct结构) - 布尔值
- 字符类型
char - 各种整数、浮点数
- 数组(rust数组的元素数据类型和数组长度都是固定不变的)
- 元组
除此之外,对于动态大小的类型(vector、String),则数据部分保存在堆中,并在栈中留下胖指针指向实际的数据,栈中的胖指针结构是静态大小的。
以上分类需要注意几点:
- 将栈中数据赋值给变量时,数据直接存放在栈中。比如
i32类型的数值33,33直接存放在栈内,而不是在堆中存放33并在栈中存放指向33的指针 - 因为类型的值默认都分布在栈中(即便是动态类型的数据,但也通过胖指针改变了该类型的值的表现形式),所以创建某个变量的引用时,引用的是栈中的那个值
- 有些数据是0字节的,不需要占用空间,比如
() - 尽管容器结构中(如数组、元组、结构体)可以存放任意数据,但保存在容器中的要么是原始类型的栈中值,要么是指向堆中数据的引用,所以这些容器类型的值也在栈中。例如,对于
struct User {name: String},name字段存储的是String类型的胖指针,String类型实际的数据则在堆中 - 尽管
Box::new(T)可以将类型T的数据放入堆中,但Box类型本身是一个结构体,它是一个智能指针,保存在栈中
rust除了使用堆栈,还使用全局内存区(静态变量区和字面量区)
编译器会将全局内存区的数据直接嵌入在二进制程序文件中,当启动并加载程序时,嵌入在全局内存区的数据被放入内存的某个位置。
全局内存区的数据是编译期间就可确定的,且存活于整个程序运行期间。
字符串字面量、static定义的静态变量(全局变量)都会硬编码嵌入到二进制程序的全局内存区。
比如:
1 | fn main(){ |
上面代码中的几个变量都使用了字符串字面量,且使用的都是相同的字面量"hello",在编译期间,它们会共用同一个"hello",该"hello"会硬编码到二进制程序文件中。当程序被加载到内存时,该被放入到全局内存区,它在全局内存区有自己的内存地址,当运行到以上各行代码时:
- 代码(1)、(3)、(4),将根据地址取得其引用,并分别保存到变量
_s、_arr各元素、_tuple元素中 - 代码(2),将根据地址取得数据并将其拷贝到堆中(转换为
Vec<u8>的方式存储,也就是String类型的底层存储方式)
常量将在编译期间直接以硬编码的方式内联(inline)插入到使用常量的地方
所谓内联,即将它代表的值直接替换到使用它的地方。
比如,定义了常量const ABC:i32 = 33,在第100行和第300行处都使用了常量ABC,那么在编译期间,会将33硬编码到第100行和第300行处。
rust中,除了const定义的常量会被内联,某些函数也可以被内联。和C语言的inline一样,rust可以通过属性将函数进行内联,表示将该函数对应的代码体直接展开并插入到调用该函数的地方,这样就没有函数调用的开销(比如没有调用函数时申请栈帧、在寄存器保存某些变量等的行为),效率会更高一些。但只有那些频繁调用的短函数才适合被内联,并且内联会导致程序的代码膨胀。具体可参考:The inline attribute。
3 通过位置和值理解内存模型
在rust中,非常有必要理解的概念是位置(表达式)和值(表达式),理解这两个概念,对理解rust的内存布局、引用、指针、变量等等都有很大帮助。
位置指的是某一块内存位置,它有自己的地址和空间以及自己所保存的值。位置可能位于栈中,可能位于堆中,也可能位于全局内存区。
值指的是存储到位置中的数据(保存在内存某个位置中的数据)。值的类型有多种,比如数值类型、指针类型、指针(裸指针、胖指针)、字符类型等等。
简单来说,位置一般被称为左值,值一般叫做右值。值表达式引用了某个存储单元地址中的数据,它相当于数据,只能进行读操作。
从语义角度来说,位置表达式代表了持久性数据,值表达式代表了临时数据。位置表达式一般有持久的状态,值表达式要么是字面量,要么就是表达式求值过程中创建的临时值。
理解变量、位置和值的关系
下面通过一个实例来进行说明。当使用let声明变量时,需要产生一个位置来存放数据:
1 | let a = 1; |
内存中的示意图大致如下:

对于第一句:
a称为变量名。变量名是语言层面上提供的一个别名,它是对内存位置的一个人类可读的代号名称,在编译期间,变量名会被移除掉并替换为更低级的代号甚至替换为内存地址- 变量名
a对应栈中的一个位置,这个位置中保存了值1,这是由于整数类型的大小已知,默认将其保存在栈中 - 位置有自己的内存地址,比如
0x612 - 有时候,会将这种声明变量时的位置看作是变量(注意不是变量名),或者将变量看作是位置。无论如何看待两者,我们内心需要明确的是,变量或这种位置,是栈中的一块内存
- 每个位置或变量,都是它所存放的值的所有者。因为每个值都只能存放在一个位置中,所以每个值都只能有一个所有者
对于第二句:
- 由于这是一个动态类型
Vec,它实际保存的值在堆中 - 这里产生了两个位置,一个位置在堆内存中,用于存放实际数据,它是由一连串空间连续的小位置组成的一个大位置,每个小位置存放了对应的值;第二个位置在栈中,它存放的是
Vec的胖指针 - 这两个位置都有自己的地址,都有自己的值。
- 其中,栈中的那个位置,是变量声明时显式创建的位置,这个位置代表的是
Vec类型的变量,而堆中的位置是自动隐式产生的,这个位置和变量没有关系,唯一的关联是栈中的那个位置中有一根指针指向这个堆中的位置。
需要说明的是,对于上面的Vec,Vec的值指的是存放在栈中那个位置内的数据,而不是堆中的存放的实际数据。也就是说,变量v的值是那个胖指针,而不是堆中的那串实际数据。更严格地说,Vec类型的值,指的是那个胖指针数据,而不是实际数据,变量v是胖指针这个值的所有者,而不是实际数据的所有者。这种变量和值之间的关系和其它某些语言可能有所不同。
理解变量的引用
rust中的引用是一种指针,只不过rust中还附带了其它编译期特有的含义,例如是引用会区分是否可变、引用是借用概念的实现形式。
但不管如何,rust中的引用是一种原始数据类型,它的位置认在栈中,保存的值是一种地址值,这个地址指向它所引用的目标。
这个地址的目标实际上指向它所指向的那个变量(即指向位置)。
例如:
1 | let a = 1; |
示意图如下:

对于这个示例来说,在这个示例中,变量a对应栈中的一个位置,这个位置中保存了数据值1,这个位置有一个地址0x624,而对于变量aa,它也对应栈中的一个位置0x604,这个位置中保存了一个地址值,这个地址的值为0x624,即指向变量a的位置。
为什么引用中的地址不是指向原始数据呢?这可以从位置和值的角度来理解,例如上面的let vv = &v;,v是一个位置,这个位置保存的是Vec的胖指针数据,也就是说,v的值是这个胖指针而不是堆中的那块实际数据,所以vv引用v时,引用的是v的位置,而不是实际数据。
另外,rust是一门强调安全的语言,它不允许存在对堆中同一个内存的多个指向,因为这可能会导致重复释放同一块堆内存的危险。换句话说,至始至终,只有最初创建这块堆内存的v变量才指向堆中这块数据。当然,v中的值(即栈中位置中保存的值)可能会被绑定给另外一个变量,那么这个接收变量就会成为唯一一个指向堆中数据的变量。
虽然rust不允许对堆中同一个内存的多个指向,但是却允许对栈中同一个数据的多个指向:
1 | let a = 1; |
这是因为栈内存由编译器负责维护,编译器知道栈中的某个内存是否安全(比如判断变量是否离开作用域被销毁、判断生命周期),而堆内存是由程序员负责维护,程序员的行为是不安全的。
何时创建位置和值
创建位置的地方主要可以大致总结为:
- 会产生变量的时候,就会产生位置
- 需要保存某个值的时候,就会产生位置
- 会产生新值的时候(例如引用会新产生一个地址值,解引用会产生对应的结果值),就会产生位置
- 使用值的时候,就会产生位置
以上是显式产生位置的方式,还有隐式产生的位置。例如,在初始化一个Vec并赋值给变量时,堆内存中的那个位置就是隐式创建的。
位置一旦初始化赋值,就会有一个永远不变的地址,直到销毁。换句话说,变量一旦初始化,无论它之后保存的数据发生了什么变化,它的地址都是固定不变的。也说明了,编译器在编译期间就已经安排好了所有位置的分配。
1 | fn main() { |
输出结果:
1 | n: 0x7ffe71c47d60 |
4 理解所有权的转移
数据的移动
在rust中没有深浅拷贝的概念,但有移动(move)、拷贝(copy)和克隆(clone)的概念。
看下面的赋值操作,在其他语言中这样赋值是正确的,但在rust中这样的语法会报错。
1 | fn main(){ |
上面的示例中,变量s1绑定了String数据,不过这里要注意的是,String数据指的是胖指针结构而不是指堆中的那些实际数据,胖指针指向堆中的实际数据。此时该数据的所有者是s1。
当执行let s2 = s1;时,将不会拷贝堆中数据赋值给s2,也不会像其他语言一样让变量s2也绑定堆中数据,即不会拷贝堆数据的引用赋值给s2。
因此,下图的内存引用方式不适用于rust。

如果rust采用这种内存引用方式,按照rust的所有权规则,变量在跳出作用域后就销毁堆中对应数据,那么在s1和s2离开作用域时会导致二次释放同一段堆内存,这会导致内存污染。
rust采用非常直接的方式,当执行let s2 = s1;时,直接让s1无效(s1这个位置仍然存在,只是变成未初始化变量,rust不允许使用未初始化变量,可重新为其赋值),而是只让s2绑定堆内存的数据。也就是将s1移动到s2,也称为值的所有权从s1转移给s2。

所有权移动后修改数据
定义变量的时候,加上mut表示变量可修改。当发生所有权转移时,后拥有所有权的变量也可以加上mut。
1 | let mut x = String::from("hello"); |
移动真的只是移动吗?
比如下面的示例:
1 | let s1 = String::from("hello"); |
上面已经分析过,值的所有权会从变量s1转移到变量s2,所有权的转移,涉及到的过程是拷贝到目标变量,同时重置原变量到未初始状态,整个过程就像是进行了一次数据的移动。但注意,上面示例中拷贝的是栈中的胖指针,而不是拷贝堆中的实际数据,因此这样的拷贝效率是相对较高的。
所有权转移之后,将只有新的所有者才会指向堆中的实际数据,而原变量将不再指向堆中实际数据,因此所有权转移之后仍然只有一个指针指向堆中数据。
Move不仅发生在变量赋值过程中,在函数传参、函数返回数据时也会Move,因此,如果将一个大对象(例如包含很多数据的数组,包含很多字段的struct)作为参数传递给函数,是否会让效率很低下?
按照上面的结论来说,确实如此。但rust编译器会对Move语义的行为做出一些优化,简单来说,当数据量较大且不会引起程序正确性问题时,它会传递大对象的指针而非内存拷贝。
此外,对于胖指针类型的变量(如Vec、String),即使发生了拷贝,其性能也不差,因为拷贝的只是它的胖指针部分。
总之,Move虽然发生了内存拷贝,但它的性能并不会太受影响。
Copy特征
默认情况下,在将一个值保存到某个位置时总是进行值的移动(实际上是拷贝),使得只有目标位置才拥有这个值,而原始变量将变回未初始化状态,也就是暂时不可用的状态。这是rust的Move语义。
rust还有Copy语义,和Move语义几乎相同,唯一的区别是Copy后,原始变量仍然可用。
前面说过,Move实际上是进行了拷贝,只不过拷贝后让原始变量变回未初始化状态了,而Copy的行为,就是保留原始变量。
但rust默认是使用Move语义,如果想要使用Copy语义,要求要拷贝的数据类型实现了Copy特征。
例如,i32默认就已经实现了Copy特征,因此它在进行所有权转移的时候,会自动使用Copy语义,而不是Move语义。
rust中默认实现了Copy的类型,包括但不限于:
- 所有整数类型,比如
u32 - 所有浮点数类型,比如
f64 - 布尔类型,
bool,它的值是true和false - 字符类型,
char - 元组,当且仅当其包含的类型也都是
Copy的时候。比如(i32, i32)是Copy的,但(i32, String)不是 - 共享指针类型或共享引用类型
对于那些没有实现Copy的自定义类型,可以手动去实现Copy,要求同时实现Clone,方式很简单,可以通过自动派生:
1 |
|
如何判断变量在堆上还是栈上
这是一个很有趣的问题,见下面的例子:
1 | let s1 = 5; |
这三个变量和变量的值分别保存在堆上还是栈上?
对于变量来说,也就是位置,它们肯定都是在栈上的;对于值,在互斥的Copy和Drop章节中提到过,无法为一个类型同时实现 Copy 和 Drop 特征,进一步地,我们又已知基本类型都实现了Copy,那么判断就有了依据:
实现了Copy特征的类型的值保存在栈上,反之,没有实现的类型的值保存在堆上。
这个例子中,5这个值保存在栈上;
由于Box和Vec都实现了Drop,那么:vec[1,2,3,4]的四个值保存在堆上,直观的表示如下:
1 | (stack) (heap) |
Box指针保存在堆上且指针指向的值保存在堆上:
1 | (stack) (heap) ┌───┐ |
Clone特征
虽然实现Copy特征可以让原变量继续拥有自己的值,但在某些需求下,不便甚至不能去实现Copy。这时如果想要继续使用原变量,可以使用clone()方法手动拷贝变量的数据,同时不会让原始变量变回未初始化状态。
1 | fn main(){ |
但不是所有数据类型都可以进行克隆,只有那些实现了Clone特征的类型才可以进行克隆,常见的数据类型都已经实现了Clone,因此它们可以直接使用clone()来克隆。
要注意Copy和Clone时的区别,如果不考虑自己实现Copy特征和Clone特征,而是使用它们的默认实现,那么:
Copy时,只拷贝变量本身的值,如果这个变量指向了其它数据,则不会拷贝其指向的数据Clone时,拷贝变量本身的值,如果这个变量指向了其它数据,则也会拷贝其指向的数据
也就是说,Copy是浅拷贝,Clone是深拷贝,rust会对每个字段每个元素递归调用clone(),直到最底部。
1 | fn main() { |
所以,使用Clone的默认实现时,clone()操作的性能是较低的。但可以自己实现自己的克隆逻辑,也不一定总是会效率低。比如Rc,它的clone用于增加引用计数,同时只拷贝少量数据,它的clone效率并不低。
函数参数和返回值的所有权移动
函数参数类似于变量赋值,在调用函数时,会将所有权移动给函数参数。
函数返回时,返回值的所有权从函数内移动到函数外变量。
例如:
1 | fn main(){ |
很多时候,变量传参之后丢失所有权是非常不方便的,这意味着函数调用之后,原变量就不可用了。为了解决这个问题,可以将变量的引用传递给参数。引用是保存在栈中的,它实现了Copy特征,因此在传递引用时,所有权转移的过程实际上是拷贝了引用,这样不会丢失原变量的所有权,效率也更高。
5 深入理解Move
所有权的转移并不仅仅只发生在这两种相对比较明显的情况下。例如,解引用操作也需要转移所有权:
1 | let v = &vec![11, 22]; |
你会得到如下错误:
1 | --> src\main.rs:5:9 |
可以从位置表达式和值的角度来思考,当产生了一个位置,且需要向位置中放入值,就会发生移动(Moved and copied types),只不过,这个值可能来自某个变量,可能来自计算结果(即来自于中间产生的临时变量),这个值的类型可能实现了Copy特征。
对于上面的示例来说,&vec![11, 22]中间产生了好几个临时变量,但最终有一个临时变量是vec的所有者,然后对这个变量进行引用,将引用赋值给变量v。使用*v解引用时,也产生了一个临时变量保存解引用得到的值,而这里就出现了问题。因为变量v只是vec的一个引用,而不是它的所有者,它无权转移值的所有权。
下面是一个容易令人疑惑的示例:
1 | fn main(){ |
从这个示例来看,【当值需要放进位置的时候,就会发生移动】,这句话似乎不总是正确,第三行的x;取得了x的值,但是它直接被丢弃了,所以x也被消耗掉了,使得println中使用x报错。实际上,这里也产生了位置,它等价于let _tmp = x;,即将值移动给了一个临时变量。
如果上面的示例不好理解,那下面有时候会排上用场的示例,会有助于理解:
1 | fn main() { |
从结果上来看,语句块将x通过返回值的方式移出来赋值给了y,所以认为x的所有权被转移给了y。实际上,语句块中那唯一的一行代码本身就发生了一次移动,将x的所有权移动给了临时变量(参考:Temporaries),然后返回时又发生了一次移动。
14 异步编程
14.1 async异步编程模型
rust采用async异步编程模型,是一种越来越多的编程语言支持的并发编程模型。
并发编程模型不如常规的顺序编程成熟和“标准化”,每个主流语言都对自己的并发模型进行过权衡取舍和精心设计,rust也不例外。下面的列表可以帮助大家理解不同并发模型的取舍:
- OS threads(OS 线程)不需要对编程模型进行任何更改,这使得并发表达变得非常容易。但是,线程之间的同步可能很困难,而且性能开销很大。线程池可以减轻其中一些成本,但不足以支持大量 IO 绑定的工作负载。
- Event-driven programming(事件驱动模型)与回调相结合,可以非常高效,但往往会导致冗长的“非线性”控制流。数据流和错误传播通常很难追踪,还会导致代码可维护性和可读性的大幅降低,比如js的“回调地狱”。
- Coroutines(协程)与线程一样,不需要更改编程模型,这使得它们易于使用。go在语言层面天然支持协程,和 async模型一样,它们也可以支持大量的任务。但是,它们抽象掉了对系统编程和自定义运行时实现者很重要的低级细节。
- The actor model(actor模型)是 erlang 的杀手锏之一,它将所有并发计算分割成一个一个单元,这些单元被称为
actor, 单元之间通过消息传递的方式进行通信和数据传递,跟分布式系统的设计理念非常相像。由于actor模型跟现实很贴近,因此它相对来说更容易实现,但它留下了许多实际问题没有解决,例如流量控制和重试逻辑。 - async/await模型是rust选择的异步模型,该模型性能高,支持底层编程细节,同时又像线程和协程那样无需过多的改变编程模型,但有得必有失,async模型的问题就是内部实现机制过于复杂,对于用户来说,理解和使用起来也没有线程和协程简单。
总之,rust经过权衡取舍后,最终选择了同时提供多线程编程和 async 编程。不过,尽管许多语言都支持async异步编程(比如js、C#),但一些细节因实现而异。 rust的异步实现在几个方面与大多数语言不同:
- Future在rust中是惰性的,只有在被轮询(poll)时才会运行, 因此丢弃一个 Future 会阻止它未来再被运行,你可以将Future理解为一个在未来某个时间点被调度执行的任务。
- async在rust中使用开销是零, 意味着只有你能看到的代码(用户自己的代码)才有性能损耗,你看不到的(async 内部实现)都没有性能损耗,例如,你可以无需分配任何堆内存、也无需任何动态分发来使用 async ,这对于热点路径的性能有非常大的好处,正是得益于此,rust的异步编程性能才会这么高。
- rust没有内置异步调用所必需的运行时,相反,运行时由社区维护的
crate提供,比如tokio。 - rust提供单线程和多线程运行时,它们各有优缺点。
14.2 Async Rust的现状
简而言之,异步 Rust 比同步 Rust 更难使用,并且可能导致更高的维护负担,但作为回报,它会为您提供一流的性能。异步 Rust 的所有领域都在不断改进,因此这些问题的影响会随着时间的推移而减弱。当然,这并不影响我们在生产级项目中使用,因为社区中一些优秀的第三方支持已经很完善了。
语言和库的支持
虽然 Rust 本身支持异步编程,但大多数异步应用程序都依赖于社区包提供的功能。这是因为 async 的底层实现非常复杂,且会导致编译后文件体积显著增加,因此 Rust 没有选择像 Go 语言那样内置了完整的特性和运行时,而是选择了通过 Rust 语言提供了必要的特性支持,再通过社区来提供 async 运行时的支持。 因此要完整的使用 async 异步编程,你需要依赖以下特性和外部库:
- 标准库提供了最基本的特征、类型和函数,例如
Future特征。 - Rust 编译器直接支持
async/await语法。 - 众多实用的类型、宏和函数由官方开发的
futures包提供(不是标准库),它们可以在任何异步 Rust 应用程序中使用。 async代码的执行、IO操作、任务创建和调度等等复杂功能由社区的async运行时提供,例如tokio和async-std。
你可能习惯于同步 Rust 的一些语言特性,这些特性在异步 Rust 中尚不可用。值得注意的是,Rust 不允许在特征中声明异步函数。相反,需要使用变通方法来获得相同的结果,这可能会更加冗长。
自 2022 年 11 月 17 日起,编译器工具链的夜版提供了 async-fn-in-trait 的 MVP(最小化可实行产品,Minimum Viable Product),详情请参见这里。
编译和调试
在大多数情况下,异步 Rust 中的编译器和运行时错误的工作方式与它们在 Rust 中的工作方式相同。有一些值得注意的差异:
- 编译错误,由于
async编程时需要经常使用复杂的语言特性,例如生命周期和Pin,因此相关的错误可能会出现的更加频繁 - 运行时错误,编译器会为每一个
async函数生成状态机,这会导致在栈跟踪时会包含这些状态机的细节,同时还包含了运行时对函数的调用,因此,栈跟踪记录(例如panic时)将变得更加难以解读 - 一些隐蔽的错误也可能发生,例如在一个
async上下文中去调用一个阻塞的函数,或者没有正确的实现Future特征都有可能导致这种错误。这种错误可能会悄无声息的通过编译检查甚至有时候会通过单元测试。需要深入学习这些异步编程的基本概念,可以尽可能避免这些陷阱,有效地降低遇到这些错误的概率
兼容性考虑
异步和同步代码不能总是自由组合。例如,无法直接从同步函数调用异步函数。同步和异步代码也倾向于促进不同的设计模式,这可能使得编写适用于不同环境的代码变得困难。
即使是异步代码也不能总是自由组合。一些 crate 依赖于特定的异步运行时来运行。如果是这样,它通常在 crate 的依赖列表中指定。
甚至于有时候,异步代码之间也存在类似的问题,如果一个库依赖于特定的 async 运行时来运行,那么这个库非常有必要告诉它的用户,它用了这个运行时。否则一旦用户选了不同的或不兼容的运行时,就会导致不可预知的麻烦。
性能特点
异步 Rust 的性能取决于您正在使用的异步运行时的实现。尽管支持异步 Rust 应用程序的运行时相对较新,但它们对于大多数实际工作负载来说表现得非常好。
目前主流的 async 运行时几乎都使用了多线程实现,相比单线程虽然增加了并发表现,但是对于执行性能会有所损失,因为多线程实现会有同步和切换上的性能开销,若你需要极致的顺序执行性能,那么 async 目前并不是一个好的选择。
另一个被忽视的用例是对延迟敏感的任务,这对驱动程序、GUI 应用程序等很重要。任务的执行次序需要能被严格掌控,而不是交由运行时去自动调度,后者会导致不可预知的延迟。此类任务取决于运行时或操作系统支持,以便进行适当的调度。
以上的两个需求,目前的 async 运行时并不能很好的支持,在未来可能会有更好的支持,但在此之前,可以尝试用多线程解决。
下面开始介绍Future执行器与任务调度。
14.3 Future 特征
Future 特征是 Rust 异步编程的核心。Future 定义为是一种可以产生值的异步计算(尽管该值可能为空,例如 () )。
简化版的 Future 特征如下:
1 | trait SimpleFuture { |
提到过 Future 需要被执行器poll(轮询)来推进运行,如果在当前 poll 中, Future 完成,则会返回 Poll::Ready(result) ;如果还不能完成,它返回 Poll::Pending ,并且安排一个 wake 函数:当未来 Future 准备好进一步执行时调用 wake 函数。当 wake 被调用时,驱动 Future 的执行者会再次调用 poll ,让 Future 有更多的进展。
如果没有 wake 方法,那执行器无法知道某个 Future 是否可以继续被执行,除非执行器定期的轮询每一个 Future,确认它是否能被执行,但这种作法效率较低。而有了 wake,Future 就可以主动通知执行器,然后执行器就可以精确的执行该 Future。 这种方式的转变比遍历每一个Future要高效。
作为一个例子,考虑我们想要从可能有或可能没有可用数据的套接字中读取的情况。如果有数据,我们可以读入并返回 Poll::Ready(data) ,但是如果没有数据准备好,我们的Future就被阻塞了,无法再前进。当没有数据可用时,我们必须注册 wake ,以便在套接字上数据准备就绪时调用,这将告诉执行者我们的Future已准备好取得进展。一个简单的 SocketRead的 Future 可能看起来像这样:
1 | pub struct SocketRead<'a> { |
Future 的这种模型允许将多个异步操作组合在一起而无需临时分配。一次运行多个Future或将Future链接在一起可以通过无分配的状态机实现,如下所示:
1 | trait SimpleFuture { |
Future 的这种模型允许将多个异步操作组合在一起而无需中间分配。 类似的,多个Future也可以一个接一个的连续运行:
1 | /// 一个SimpleFuture, 它使用顺序的方式,一个接一个地运行两个Future |
这些示例展示了如何使用 SimpleFuture 特征来表达异步控制流,而不需要多个分配的对象和深度嵌套的回调。了解了基本的控制流程后,让我们来看看真正的 Future 特征及其与SimpleFuture不同之处。
1 | trait Future { |
注意到的第一个变化是我们的 self 类型不再是 &mut Self ,而是变成了 Pin<&mut Self> 。我们将在后面详细讨论Pin,现在只需要知道它允许我们创建在内存中固定的Future。不可移动的对象可以在它们的字段之间存储指针,例如自引用数据结构 struct MyFut { a: i32, ptr_to_a: *const i32 } 。固定是启用 async/await 所必需的。
其次, wake: fn() 已更改为 &mut Context<'_> 。在 SimpleFuture 中,我们使用了对函数指针 ( fn() ) 的调用来告诉未来的执行者应该轮询所讨论的Future。但是,由于 fn() 只是一个函数指针,它不能存储任何关于 Future 调用了 wake 的数据。Context结构体是异步任务的上下文,使用该结构可以在唤醒任务时携带数据,当一个 Future 调用 wake 后,执行器就知道是哪个 Future 调用了 wake,从而调用这个Future的poll。
在真实场景中,像 Web 服务器这样的复杂应用程序可能有数千个不同的连接,它们的唤醒都应该单独管理。 Context 类型通过提供对 Waker 类型值的访问来解决这个问题,该值可用于唤醒特定任务。
14.4 使用 Waker 来唤醒任务
深入Waker与Context的内部
上一节简单提到了为什么要使用Context替换Waker,现在来看看这个接口的Context具体是什么:
1 | pub struct Context<'a> { |
根据源码可以看到,它将Waker包装在其中,除此之外,还有两个PhantomData的标记字段,这个暂且忽略。我们下面关心的是Waker的定义:
1 | pub struct Waker { |
Waker结构内又包装了一个RawWaker,它定义了执行器特定的唤醒行为。
1 | pub struct RawWaker { |
它由一个数据指针和一个自定义 RawWaker 行为的虚函数指针表RawWakerVTable组成,虚函数指针表的定义如下:
1 | pub struct RawWakerVTable { |
定义了四个接口,每一个都是函数,由不同的行为触发。具体来说就是
clone:当RawWaker被克隆时,这个函数将被调用,例如,当存储RawWaker的Waker被克隆时。此函数的实现必须保留此附加实例RawWaker和关联任务所需的所有资源。并且在生成的RawWaker上调用wake应该会唤醒与原始RawWaker唤醒的相同任务。wake:在Waker上调用wake时将调用此函数。它必须唤醒与此RawWaker关联的任务。此函数的实现必须确保释放与此RawWaker实例和关联任务关联的所有资源。wake_by_ref:在Waker上调用wake_by_ref时将调用此函数。它必须唤醒与此RawWaker关联的任务。此功能类似于wake,但不得使用提供的数据指针。drop:当删除Waker时将调用此函数。此函数的实现必须确保释放与此RawWaker实例和关联任务关联的所有资源。
由于rust自身并不提供异步运行时,它只在标准库里规定了一些基本的接口,至于怎么实现,可以由各个运行时(如 tokio等)自行决定。
所以在标准库源码中,你只会看到这些接口的定义,以及高层接口的实现,比如Waker下的wake方法,只是调用了 vtable里的wake()而已:
1 | impl Waker { |
也就是说,如果想继续深入下去查看vtable的具体实现,就需要去查看第三方异步运行时的源码了。比如futures库的waker实现。
手动实现一个Future
Future在第一次被 poll 时无法完成是很常见的。发生这种情况时,Future需要确保在准备好取得更多进展后能够再次使用poll对其进行轮询。这是通过 Waker 类型完成的。
每次轮询Future时,都会将其作为task的一部分进行轮询。task是已提交给执行者的顶级Future。
Waker 提供了一个 wake() 方法,可以用来告诉执行者关联的任务应该被唤醒。当调用 wake() 时,执行者知道与 Waker 关联的任务已准备好取得进展,应再次轮询其Future。Waker 还实现了 clone() ,因此它可以被复制和存储。
在接下来的小节,让我们尝试使用 Waker 实现一个简单的定时器Future。
构造一个定时器
在这个例子中,我们将在创建计时器时启动一个新线程,休眠所需的时间,然后在时间窗口结束时向计时器Future发出信号。
首先,使用 cargo new --lib timer_future 启动一个新项目,并将需要用到的crate导入添加到 src/lib.rs :
1 | use std::{ |
让我们从定义Future类型本身开始。我们的Future需要一种方法让线程传达计时器已经完成并且Future应该完成。我们将使用 Arc<Mutex<..>> 共享状态,在线程和计时器Future之间进行通信。
1 | pub struct TimerFuture { |
下面是 Future 的具体实现:
1 | impl Future for TimerFuture { |
代码很简单,只要新线程设置了 shared_state.completed = true ,那任务就能顺利结束。如果没有设置,会为当前的任务克隆一份 Waker ,这样新线程就可以使用它来唤醒当前的任务。
最后,我们需要 API 来实际构造计时器并启动线程:
1 | impl TimerFuture { |
一个简单的定时器 Future 已经创建成功,现在,需要一个执行器来管理Future,并让其运行起来。
创建执行器
rust 的 Future 是惰性的:除非主动驱动完成,否则它们不会做任何事情。推动 future 完成的一种方法是在 async 函数中对它进行 .await 处理,但这只会将问题推上一个层次:谁将运行从顶级 async 函数返回的 futures?答案是我们需要一个 Future 执行器。
Future 执行者获取一组顶层 Future 并在 Future 可以取得进展时通过调用 poll 来运行它们直到完成。通常,执行者将执行一次 poll 以开始运行Future。当 Future 表示它们已准备好通过调用 wake() 取得进展时,它们将被放回队列并再次调用 poll ,重复直到 Future 完成。
对于这个例子,我们依赖 futures 包来实现 ArcWake 特征,它提供了一种构造 Waker 的简单方法。编辑 Cargo.toml 添加一个新的依赖:
1 | [dependencies] |
接下来,我们需要在 src/main.rs 的顶部导入以下内容:
1 | use futures::{ |
执行器需要从一个消息通道( channel )中拉取事件,执行者将从通道中拉出事件并运行它们。当一个任务准备好做更多的工作(被唤醒)时,它可以通过将自己放回通道来安排自己再次被轮询,然后等待执行器 poll 。
在这种设计中,执行者本身只需要任务通道的接收端。用户将获得一个发送端,以便他们可以产生新的Future。任务本身只是可以重新安排自己的Future,所以我们将它们存储为与发送者配对的Future,任务可以使用它来重新排队。
1 | /// 任务执行器,负责从通道中接收任务然后执行 |
首先,我们需要定义一个消息通道,这里采用同步的通道,关于通道我们在同步通道里介绍过。new_executor_and_spawner函数用于创建一个同步的通道,其中task_sender为发送端,ready_queue为接收端,将其分别放入Spawner和Executor。然后基于这个通道封装了一个Task任务结构体,它具有两个参数,future就是进行中的任务,task_sender用于将自身放回到通道中。
还需要为Spawner实现一个方法,以便轻松生成新的futures。此方法将采用Future类型,将其装入Box,并在其中创建一个新的 Arc<Task> ,可以将其排队到执行程序中。
在通道中传输的类型使用Arc<Task>,是因为在多线程中传递值需要线程安全的引用计数。
1 | impl Spawner { |
这个方法的函数签名中,有一点需要注意,future被定义为实现了Future<Output = ()> + 'static + Send特征的任何类型,但是翻遍标准库,也找不到future.boxed()方法。那么调用future.boxed()应该提示找不到方法才对,但这样写是正确的,为什么?
实际上boxed方法被定义在futures包中的FutureExt特征,boxed 是 FutureExt特征中定义的方法,FutureExt 特征 为所有实现了 Future 特征的类型(比如这里的 impl Future<Output = ()>) 提供了一些额外的方法,例如 boxed、map、then 等等,具体可参考futures::future::FutureExt。由于一开始在 src/main.rs 的顶部导入了FutureExt,它实现了boxed方法,因此编译器可以找到该方法,反之,如果在顶部导入时去掉这个特征,编译会报错并提示:
1 | error[E0599]: no method named `boxed` found for type parameter `impl Future<Output = ()> + 'static + Send` in the current scope |
boxed的函数签名为:
1 | fn boxed<'a>(self) -> Pin<Box<dyn Future<Output = Self::Output> + Send + 'a, Global>> |
它将future用Box包装,然后将其固定(Pin)并返回,这里留下一个疑问,什么是Pin,为什么要Pin?这个问题留到下一小节介绍,见:Pin与Unpin。
接下来,spawn函数创建了一个新的任务Task,并用Arc包装,然后将这个任务发送到通道中。
为了轮询Futures,我们需要创建一个 Waker 。Waker 负责安排一个任务,以便在调用 wake 后再次轮询。创建新 Waker 的最简单方法是实现 ArcWake 特征,然后使用 waker_ref 或 .into_waker() 函数将 Arc<impl ArcWake> 转换为 Waker 。现在来为我们的任务实现 ArcWake ,让它们变成 Waker 并被唤醒:
1 | impl ArcWake for Task { |
当任务实现了 ArcWake 特征后,它就变成了 Waker,在调用 wake() 对其唤醒后会将任务复制一份所有权( Arc ),然后将副本发送到任务通道中。
在通道的另一头,执行者需要获取任务并轮询。让我们来实现这部分代码:
1 | impl Executor { |
恭喜!我们终于拥有了自己的执行器,下面再来写一段代码使用该执行器去运行之前的定时器 Future :
1 | fn main() { |
执行者和系统IO
在上一节Future 特征中,我们讨论了这个在套接字上执行异步读取的 future 示例:
1 | pub struct SocketRead<'a> { |
这个 future 将读取套接字上的可用数据,如果没有可用数据,它将让出当前线程的所有权,请求在套接字再次可读时唤醒其任务。但是,从这个例子中并不清楚 Socket 类型是如何实现的,尤其是 set_readable_callback 函数,并不知道是如何工作的。一旦套接字变得可读,我们如何安排调用 wake() ?一种选择是让一个线程持续检查 socket 是否可读,并在适当的时候调用 wake() 。然而,这将是非常低效的,需要为每个阻塞的 IO futures创建一个单独的线程。这会大大降低我们异步代码的效率。
在实践中,这类问题通常是通过操作系统提供的IO多路复用机制完成。例如 Linux 上的 epoll 、FreeBSD 和 Mac OS 上的 kqueue 、Windows 上的 IOCP 和 Fuchsia 上的 port (所有这些通过跨平台 Rust crate mio 公开)。这些原语都允许线程阻塞多个异步 IO 事件,一旦其中一个事件完成就返回。实际上,这些 API 通常看起来像这样:
1 | struct IoBlocker { |
Futures执行器可以使用这些原语来提供异步 IO 对象,例如可以配置在特定 IO 事件发生时运行的回调的套接字。在上面的 SocketRead 示例中, Socket::set_readable_callback 函数可能类似于以下伪代码:
1 | impl Socket { |
这样,我们只需要一个执行器线程,它会接收 IO 事件并将其分发到对应的 Waker 中,接着后者会唤醒相关的任务,最终通过执行器 poll 后,任务可以顺利地继续执行,这种 IO 读取流程可以不停的循环,直到 socket 关闭。
14.5 async/await语法
语法糖与脱糖
async 是rust中的关键字,它用于告诉编译器一个函数可以被异步处理,这个关键字可以看作是语法糖。
让我们用一个非常轻量级的例子来描绘它。假设我们有一个函数应该返回一个数字,但应该能够异步处理。使用提到的关键字,可以编写以下代码:
1 | async fn give_number() -> u32 { |
使用 async ,编译器会将函数脱糖为返回实现了 Future 的函数,类似于下面的代码所示。
1 | fn give_number() -> impl Future<Output = u32> { |
如果给一个普通的函数返回impl Future<OutPut>类型,它的行为和async fn是一致的。
两种使用acync的方式
第一种就是async fn,也就是函数定义:
1 | async fn foo() -> u8 { 5 } |
前面提到了,foo()会被脱糖为返回实现了Future<Output = u8>类型的值,foo().await将返回类型为u8的值。
第二种是async块:
1 | fn bar() -> impl Future<Output = u8> { |
这里async块同样返回实现了Future<Output = u8>类型。
async的生命周期
与传统函数不同,采用引用或其他非 'static 参数的 async fn 会返回受参数生命周期限制的 Future :
1 | async fn foo(x: &u8) -> u8 { *x } |
这等价于下面的:
1 | fn foo<'a>(x: &'a u8) -> impl Future<Output = u8> + 'a { |
这意味着从 async fn 返回的Future必须被 .await 编辑,而它的非 'static 参数仍然有效。也就是说x必须比 Future活得更久。在调用函数后立即调用Future的 .await 的常见情况下(如 foo(&x).await ),不会有问题。但是,如果存储Future或将其发送到另一个任务或线程,这可能是一个问题。
下面这段代码会报错,因为x的生命周期只到bad函数的结尾,但是borrow_x返回的Future会活得更久:
1 | use std::future::Future; |
一种解决方案是,将带有引用作为参数的 async fn 转换为 'static 的Future,方法是将参数与对 async fn 的调用捆绑在 async 块中,通过将参数移动到 async 块内, 将它的生命周期扩展到 'static, 并跟返回的Future保持了一致:
1 | use std::future::Future; |
async move
async可以配合使用move关键字来将环境中变量的所有权转移到语句块内,就像闭包那样,好处是不用解决借用生命周期的问题,坏处就是无法跟其它代码实现对变量的共享。
1 | // 多个不同的 `async` 语句块可以访问同一个本地变量,只要它们在该变量的作用域内执行 |
14.6 Pin与Unpin
要轮询Futures,必须使用称为 Pin<T> 的特殊类型固定它们。但首先要了解什么是Pin<T>,我们先从非异步讲起。
为何需要Pin
在自引用与循环引用中,介绍了如何构建一个自引用的结构,比如通过unsafe 实现:
1 |
|
我们来试一下这个自引用结构体的move:
1 | fn main() { |
这里初始化了两个自引用结构,值分别为test1和test2,std::mem::swap用于交换两个可变位置的值,这是打印结果:
1 | a: test1, b: test1 |
可以发现这与我们期望的结果不符,问题出在哪里?原因是test1结构体中的字段b是一个指向字段a的指针,它在栈上存的是字段a的地址。通过swap()函数交换两个Test结构体之后,字段a和b分别移动到对方的内存区域上,但是a和b本身的内容没有变。也就是指针b依然指向的是原来的地址,但是这个地址现在已经属于另外一个结构体了,这不仅不是自引用结构体,更可怕的是这个指针可能导致更危险的问题,这是rust决不允许出现的。下面这张图可以帮助理解:

由于rust中的异步编程async/await是基于自引用结构体实现的。如果不能从根源上解决这个问题,rust号称的内存安全的根基就完全动摇了。接下来我们找一下导致这个问题的根源,然后想办法从根源上解决它。
问题的根源
我们发现上面的例子最关键的一行代码是std::mem::swap(&mut test1, &mut test2),就是它导致了我们自引用结构体失效引发了内存安全问题。从这个角度出发,只要避免这个swap()函数应用到我们自引用结构体上,那就解决了自引用结构体移动导致内存安全的问题。
可是怎么去避免呢?我们先来看一下swap()方法的签名:
1 | pub fn swap<T>(x: &mut T, y: &mut T) |
它的参数要求是可变借用&mut,所以只要我们想办法在不使用unsafe的情况下不暴露可变借用即可。
还是以Test为例,它自身没办法限制自己不能可变借用,因为我们直接用&mut Test{...}就可以轻松拿到。从标准库中去找找,Box<T>呢?先不考虑它性能问题,我们把结构体T包裹在Box中,看Box能不能保证不暴露&mut T出去。看一下API文档,很遗憾不能。Box::leak()返回值就是&mut T,更甚者Box实现了DerefMut,就算不用leak()我们也可以通过* &mut Box<T>解引用轻松拿到&mut T。
事实上在Pin出现之前的标准库中确实没有这样的API能够防止在仅使用安全的rust的情况下不暴露&mut T。
Pin的作用
我们找到了问题的根源在哪,Pin就是从根源上解决这个问题的。Pin 是一个结构体:
1 | pub struct Pin<P> { |
这是一种指针的包装器,能确保该指针指向的数据不会被移动,除非它实现了 Unpin 特征。例如 Pin<&mut T> , Pin<&T> , Pin<Box<T>> ,都能确保 T 不会被移动。

Unpin是一个特征:
1 | pub auto trait Unpin {} |
如果一个类型实现了Unpin,表明这个类型可以被移动,这是一个默认实现,这意味着几乎所有类型都实现了Unpin。
与之对应的,可以被 Pin 住的值实现的特征是 !Unpin,! 代表没有实现某个特征的意思,!Unpin 说明类型没有实现 Unpin 特征,那自然就可以被 Pin 了。
那是不是意味着类型如果实现了 Unpin 特征,就不能被 Pin 了?其实,还是可以 Pin 的,毕竟它只是一个结构体,你可以随意使用,但是不再有任何效果而已,该值一样可以被移动。
例如 Pin<&mut u8> ,显然 u8 实现了 Unpin 特征,它可以在内存中被移动,因此 Pin<&mut u8> 跟 &mut u8 实际上并无区别,一样可以被移动。因此,一个类型如果不能被移动,它必须实现 !Unpin 特征。
举个例子来说,对于一个P<T>智能指针,如果它实现了Unpin特征,使用Pin<P<T>>包裹后,Pin会提供两种办法可以在“Safe Rust“下拿到&mut T。
第一种,使用:
Pin::get_mut():1
2
3
4
5pub const fn get_mut(self) -> &'a mut T
where T: Unpin,
{
self.pointer
}第二种,
Pin也对数据实现了DerefMut:1
2
3
4
5impl<P: DerefMut<Target: Unpin>> DerefMut for Pin<P> {
fn deref_mut(&mut self) -> &mut P::Target {
Pin::get_mut(Pin::as_mut(self))
}
}
如果要去掉P<T>自动实现的Unpin,即(!Unpin),也有两种方法:
1
2
3
4
5
6
7
8use std::marker::PhantomPinned;
struct Test {
a: String,
b: *const String,
_marker: PhantomPinned,
}给自己手动
impl !Unpin。前提是要使用nightly版本,并且需要引入#![feature(negative_impls)]:1
2
3
4
5
6
7
8
struct Test {
a: String,
b: *const String,
}
impl !Unpin for Test {}
只要P<T>实现的是!Unpin,Pin<P<T>>就会保证没办法在“Safe Rust“下拿到可变借用&mut T,拿不到&mut T,就没办法作用到std::mem::swap()上,这样就从根源上解决了不安全的问题。
不过,Pin还提供了一个unsafe的get_unchecked_mut()方法,不管P<T>有没有实现Unpin,你都可以通过调用这个方法拿到&mut T:
1 | pub const unsafe fn get_unchecked_mut(self) -> &'a mut T { |
这个函数是不安全的。你必须保证你永远不会将数据移出你在调用此函数时收到的可变引用,以便可以维护 Pin 类型的不变量。换句话说,rust将权限交给用户,用户需要遵守需要遵守Pin的契约,否则后果自负。
Pin的契约:对于Pin<P<T>>
- 如果
P<T>符合Unpin,那P<T>从被Pin包裹到被销毁,都要一直保证P<T>不被钉住- 如果
P<T>符合!Unpin,那P<T>从被Pin包裹到被销毁,都要一直保证P<T>被钉住
Pin的保证
上文提到Pin保证指针被“钉住”,何谓“钉住”?在标准库文档中提到:在高层次上, Pin<P> 确保任何指针类型 P 的指针在内存中都有一个稳定的位置,这意味着它不能移动到其他地方,并且在它被删除之前不能释放它的内存。这里提到了两个关键,一个是不能移动,另外一个是在被删除之前不能释放。由于“钉住”的目的是为了能够依赖一些数据在内存中的位置。要做到这一点,不仅要限制移动数据;释放、重新利用或以其他方式使用于存储数据的内存无效也受到限制。具体来说,对于固定数据,必须保持不变,即从它被固定到调用 drop 时,它的内存不会失效或被重新利用。只有当 drop 返回或出现 panic 时,内存才可能被重用。
构造Pin
在真正地解决问题之前,首先我们要梳理清楚怎样把P<T>用Pin包裹起来,也就是怎样构造一个Pin。
使用
new方法:1
2
3
4
5
6
7impl<P: Deref<Target: Unpin>> Pin<P> {
pub const fn new(pointer: P) -> Pin<P> {
// SAFETY: the value pointed to is `Unpin`, and so has no requirements
// around pinning.
unsafe { Pin::new_unchecked(pointer) }
}
}如果你的
P指向的T是Unpin的话,你可以安全地调用Pin::new()构造一个Pin。可以看到它底层实际上是调用unsafe的Pin::new_unchecked(),之所以Pin::new()是安全的,是因为Unpin的情况下Pin的”钉住“效果是不起作用的,跟正常的指针一样。使用
Pin::new_unchecked方法:1
2
3
4
5impl<P: Deref> Pin<P> {
pub const unsafe fn new_unchecked(pointer: P) -> Pin<P> {
Pin { pointer }
}
}这个方法很简单,但它是
unsafe的,该方法围绕对可能实现也可能不实现Unpin的某些数据类型的引用构造一个新的Pin<P>。标为unsafe的原因是编译器没办法保证使用者后续操作一定遵守Pin的契约。只要有存在违反契约的可能性,就必须用unsafe标记,因为这是使用者的问题,编译器没办法保证。如果使用者通过Pin::new_unchecked()构造一个Pin<P<T>>之后Pin的生命周期结束了,但P<T>依然存在,则后续操作依然可能被move,造成内存不安全。其它方法,比如
Box::pin(),Rc::pin()和Arc::pin()等,它们底层调用的都是Pin::new_unchecked方法。
使用Pin解决问题
接下来我们使用Pin来修复一下上面自引用结构体的问题。Pin可以分为栈上还是堆上,取决于你要Pin的那个指针P是在栈上还是堆上。比如Pin<&mut T>是栈上,Pin<Box<T>>是在堆上。
固定到栈上
1 | use std::pin::Pin; |
我们尝试把&mut Test钉在栈上,然后尝试去调用get_mut()作为参数传给std::mem::swap(),发现编译不通过。编译器从编译阶段就阻止我们去犯错了。
有一点需要注意,固定到栈上将始终依赖于你在编写 unsafe 时提供的保证,虽然我们知道 &'a mut T 的指针在 'a 的生命周期内被固定,但我们不知道 &'a mut T 指向的数据是否在 'a 结束后没有移动。如果这样做,它将违反Pin的契约。
一个容易犯的错误是忘记隐藏原始变量,因为你可以删除 Pin 并将数据移动到 &'a mut T 之后,比如:
1 | fn main() { |
固定到堆上
将 !Unpin 类型固定到堆中可以为我们的数据提供一个稳定的地址,因此我们知道我们指向的数据在固定后无法移动。与栈固定相反,我们知道数据将在对象的生命周期内固定。
1 | use std::pin::Pin; |
一些功能要求他们使用的futures是 Unpin的 。要将不是 Unpin 的 Future 或 Stream 与需要 Unpin 类型的函数一起使用,首先必须使用 Box::pin (创建 Pin<Box<T>> )或 pin_utils::pin_mut! 宏(创建 Pin<&mut T> )。 Pin<Box<Fut>> 和 Pin<&mut Fut> 都可以用作futures,并且都实现了 Unpin 。比如:
1 | use pin_utils::pin_mut; // `pin_utils` is a handy crate available on crates.io |
14.7 Future与Pin
现在,问题回到异步编程,为什么异步编程需要Pin,回顾一下Future特征:
1 | trait Future { |
2018年官方异步组引入Pin API的初衷就是为了解决Future内部自引用的问题。那为什么Future内部存在自引用呢,还是通过一个例子开始。
一个Future状态机的例子
来看这样一个简单的async函数:
1 | async fn write_hello_file_async(name: &str) -> anyhow::Result<()> { |
首先它创建一个文件,然后往这个文件里写入hello world!。这个函数有两个await,创建文件的时候会异步创建,写入文件的时候会异步写入。最终,整个函数对外返回一个Future。
函数可以这样被调用:
1 | write_hello_file_async("/tmp/hello").await?; |
由于处理器在处理Future时,会持续调用poll方法,于是,上面那句实际上相当于:
1 | match write_hello_file_async.poll(cx) { |
这是单个await的处理方法,更复杂一点,如果深入到write_hello_file_async内函数实现内部,它有两个await,处理的方法又是如何呢?显然,只有在处理完create()创建文件后,才能处理write_all()写入数据,所以,应该是类似这样的代码:
1 | let fut = fs::File::create(name); |
前面提到过,async函数本质上脱糖为返回impl Future类型的返回值,因此,还需要把这样的代码封装在一个Future 的实现里,对外提供出去。因此,我们需要实现一个数据结构,把内部的状态保存起来,并为这个数据结构实现Future。比如这里的类型WriteHelloFile:
1 | enum WriteHelloFile { |
这样,我们就把刚才的write_hello_file_async异步函数,转化成了一个返回WriteHelloFile,也就是实现了Future特征的函数,这二者是等价的。
下面就是为todo!部分添加Future的具体实现了:
1 | impl Future for WriteHelloFile { |
可以看到,这个Future完整实现的内部结构 ,其实就是一个状态机的迁移。这也是async脱糖后的具体实现,与我们表面上看到的:
1 | async fn write_hello_file_async(name: &str) -> anyhow::Result<()> { |
是等价的。
Future状态机实现存在的问题
在上面实现Future的状态机中,我们引用了file这样一个局部变量:
1 | WriteHelloFile::AwaitingCreate(fut) => match fut.poll(cx) { |
这个代码是有问题的,file被fut引用,但file会在这个作用域被丢弃。所以需要把它保存在数据结构中,我们可以生成一个AwaitingWriteData数据结构,把file和fut都放进去,然后在WriteHelloFile中引用它。
1 | enum WriteHelloFile { |
此时,在同一个数据结构AwaitingWriteData内部,fut指向了对file的引用,发现问题了吗,这就是自引用结构!
在问题的根源我们提到,我们需要避免自引用结构在“safe rust“下暴露可变引用&mut,否则就会出现问题。如果async函数中存在跨await的引用,在编译时生成这样的自引用匿名结构体会impl Future,当执行器调用Future::poll()函数查询状态的时候,需要一个可变引用(即&mut Self,因为是跨await的),如果不把它固定住,就相当于允许其随意操作自引用结构,包括但不限于调用std::mem::swap()之类的函数。这就是poll()必须要使用Pin<&mut Self>的原因。
14.8 Stream
Stream特征
Stream trait类似于 Future 但可以在完成之前产生多个值,类似于标准库中的 Iteratortrait:
1 | trait Stream { |
关于 Stream 的一个常见例子是消息通道( futures 包中的)的消费者 Receiver。每次有消息从 Send 端发送后,它都可以接收到一个 Some(val) 值, 一旦 Send 端关闭( drop ),且消息通道中没有消息后,它会接收到一个 None 值。
1 | async fn send_recv() { |
迭代与并发
与同步 Iterator 类似,有许多不同的方法可以迭代和处理 Stream 中的值。有组合器样式的方法,例如 map 、 filter 和 fold ,以及它们的错误时提前返回的版本 try_map 、 try_filter 和 try_fold 。
但是跟迭代器又有所不同,for 循环无法在这里使用,但是命令式风格的循环while let是可以用的,同时还可以使用next 和 try_next 方法:
1 | async fn sum_with_next(mut stream: Pin<&mut dyn Stream<Item = i32>>) -> i32 { |
上面代码是一次处理一个值的模式,但是需要注意的是:如果你选择一次处理一个值的模式,可能会造成无法并发,这就失去了异步编程的意义。要同时处理流中的多个项目,使用 for_each_concurrent 和 try_for_each_concurrent 方法:
1 | async fn jump_around( |
14.9 一次运行多个Future
到目前为止,我们主要通过使用 .await 来执行futures,它会阻塞当前任务,直到特定的 Future 完成。然而,真正的异步应用程序往往需要同时执行几个不同的操作。
现在将介绍一些同时执行多个异步操作的方法。
join!
futures 包中提供了很多实用的工具,其中一个就是 join! 宏, 它允许我们同时等待多个不同 Future 的完成,且可以并发地运行这些 Future。
在执行多个异步操作时,很容易将它们简单地 .await 成一个系列:
1 | async fn get_book_and_music() -> (Book, Music) { |
但是,这会比想象中的慢,因为在 get_book 完成之前它不会开始尝试 get_music 。因为在某些语言中,Future一旦创建就开始运行,等到返回的时候,基本就可以同时结束并返回了,比如用这样的代码:
1 | // WRONG -- don't do this |
但是rust中的Future是惰性的,直到调用.await时,才会开始运行。而那两个await由于在代码中有先后顺序,因此它们是顺序运行的。要同时正确运行两个futures,可以使用 futures::join! :
1 | use futures::join; |
join! 返回的值是一个元组,其中包含传入的每个 Future 的输出。
join_all
如果希望同时运行一个数组里的多个异步任务,可以使用 futures::future::join_all ,返回的Future将驱动其所有基础future的执行,按照提供的顺序将结果收集到 Vec<T> 。
1 | use futures::future::join_all; |
try_join!
由于 join! 必须等待它管理的所有 Future 完成后才能完成,如果你希望在某一个 Future 报错后就立即停止所有 Future 的执行,可以使用 try_join!,特别是当 Future 返回 Result 时:
1 | use futures::try_join; |
有一点需要注意,传给 try_join! 的所有 Future 都必须拥有相同的错误类型。如果错误类型不同,可以考虑使用来自 futures::future::TryFutureExt 模块的 map_err 和 err_info 方法将错误进行转换:
1 | use futures::{ |
select!
join! 只有等所有 Future 结束后,才能集中处理结果,如果你想同时等待多个 Future ,且任何一个 Future 结束后,都可以立即被处理,可以考虑使用 futures::select!:
1 | use futures::{ |
上面的函数将同时运行 t1 和 t2 。当 t1 或 t2 完成时,相应的处理程序将调用 println! ,并且该函数将结束而不会完成剩余的任务。
select! 的基本语法是 <pattern> = <expression> => <code>,,就像match语法一样。在 select 结束时重复尽可能多的future。
如果多个futures准备就绪,将在运行时伪随机选择一个。直接传递给select!的future必须实现Unpin并实现 FusedFuture。Unpin 是必需的,因为 select 使用的future不是按值获取的,而是按可变引用获取的。不过,如果是将产生 Future 的表达式传递给 select!(例如 async fn 调用)而不是按名称传递,则对 Unpin 的要求放宽,因为宏会将生成的 Future 固定在堆栈。但是,表达式传递的 Future 仍必须实现 FusedFuture 。
至于 FusedFuture 是什么,可以把它翻译为熔断(fused), FusedFuture 是必需的,因为 select!的特性为:不能在完成后轮询future。FusedFuture 由跟踪它们是否已完成的future实现。这使得在循环中使用 select 成为可能,只轮询尚未完成的future。
尚未实现FusedFuture的futures和streams可以使用 .fuse() 方法进行融合。但是请注意,如果 select! 调用处于循环中,那么在对 select! 的调用中直接为futures或streams实现FusedFuture将不足以阻止它在完成后被轮询,因此当 select! 在循环中时,用户应该注意循环外的 fuse() 。
select! 可以作为表达式使用,返回所选分支的返回值。因此, select! 中每个分支的返回类型必须相同。
examples:
1 | use futures::future; |
如前所述, select 可以直接选择返回 Future 的表达式——即使这些表达式没有实现 Unpin :
1 | use futures::future::FutureExt; |
如果在 select 之外调用类似的异步函数来生成 Future ,则必须固定 Future 以便能够将其传递给 select 。这可以通过 Box::pin 将 Future 固定在堆上或通过 pin_mut! 宏将 Future 固定在堆栈上来实现,如下:
1 | use futures::future::FutureExt; |
select 还接受一个 complete 分支和一个 default 分支。如果所有futures和streams都已用完, complete 将运行。如果没有立即准备好的futures或streams,default 将运行。
一个很实用但又鲜为人知的函数是 Fuse::terminated(),它允许构建一个已经终止的空future,稍后可以用需要运行的future填充。
当有一个任务需要在 select! 循环期间运行但它是在 select! 循环本身内部创建时,这会很方便。
另外这里还有 .select_next_some() 函数的使用。这可以与 select 一起使用以仅针对从stream返回的 Some(_) 值运行分支,而忽略 None 。
1 | use futures::{ |
当某个 Future 有多个拷贝都需要同时运行时,可以使用 FuturesUnordered 类型。下面的例子跟上个例子大体相似,但是它会将 run_on_new_num_fut 的每一个拷贝都运行到完成,而不是像之前那样一旦创建新的就终止旧的。
1 | use futures::{ |
14.10 待续
rust对于异步编程支持仍然是很新的状态,并且有一些功能仍在积极开发中,有许多不稳定的特性。如果后续异步rust有任何进展,会在这里继续补充和修改。