20.2 Future 与语法
Future 与 async 语法¶
Rust 异步编程的关键元素是 futures 和 Rust 的 async 与 await 关键字。
future 是一个现在也许还没准备好,但会在将来某个时刻准备好的值。(这个概念在很多语言里都存在,只是有时会用 task 或 promise 之类的名字。)Rust 提供了 Future trait 作为基础构件,让不同的异步操作可以用不同的数据结构来实现,同时又拥有统一的接口。在 Rust 中,future 就是那些实现了 Future trait 的类型。每个 future 都保存了自身的进度信息,以及“就绪”到底意味着什么。
async 关键字可以用于代码块和函数,表示它们可以被中断和恢复。在 async 块或 async 函数中,你可以使用 await 关键字来 await 一个 future,也就是等待它变为就绪。在 async 块或函数里,每个等待 future 的位置,都是这个块或函数可能暂停并随后恢复的点。检查 future、看看它的值是否已经可用,这个过程称为 polling(轮询)。
其他一些语言,例如 C# 和 JavaScript,也用 async 和 await 关键字进行异步编程。如果你熟悉这些语言,可能会注意到 Rust 在语法处理上存在一些明显差异。我们会看到,这样设计是有充分理由的。
编写异步 Rust 时,大多数时候我们直接使用 async 和 await 关键字。Rust 会把它们编译成等价的、基于 Future trait 的代码,就像它把 for 循环编译成基于 Iterator trait 的等价代码一样。不过,既然 Rust 提供了 Future trait,你在需要时也可以为自己的数据类型实现它。本章中我们会见到很多函数,它们都返回拥有各自 Future 实现的类型。我们会在本章结尾回到这个 trait 的定义,进一步深入理解它的工作原理;不过眼下这些细节已经足够让我们继续前进。
这些内容可能仍然有些抽象,所以我们来写第一个异步程序:一个小型网页抓取器。我们会从命令行传入两个 URL,并发地抓取它们,然后返回那个最先完成的结果。这个例子会带来不少新语法,不过不用担心,我们会一路把需要知道的内容都解释清楚。
第一个异步程序¶
为了让本章专注于学习 async,而不是在生态系统的各种组件之间来回切换,我们准备了一个 trpl crate(trpl 是 “The Rust Programming Language” 的缩写)。它重新导出了本章需要的所有类型、trait 和函数,主要来自 [futures][futures-crate] 和 [tokio][tokio] crate。futures crate 是 Rust 异步代码实验的官方阵地,Future trait 最初就是在那里设计出来的。Tokio 则是目前 Rust 中使用最广泛的异步运行时(async runtime),尤其常见于 Web 应用。生态中也还有其他很优秀的运行时,而且它们可能更适合你的实际用途。我们在 trpl 的底层使用 tokio,是因为它经过了充分测试,也足够常用。
在某些场景下,trpl 还会对原始 API 进行重命名或包装,好让你把注意力集中在本章相关的细节上。如果你想了解这个 crate 实际做了什么,我们建议你看看[它的源码][crate-source]。你可以从中看到每个重导出项究竟来自哪个 crate,我们也留下了很多注释来解释这个 crate 的行为。
创建一个名为 hello-async 的二进制项目并将 trpl crate 作为一个依赖添加:
现在我们可以利用 trpl 提供的各种组件来编写第一个异步程序。我们要构建一个小型命令行工具:抓取两个网页,从各自页面中提取 <title> 元素,然后打印出那个最先完成整套流程的页面标题。
定义 page_title 函数¶
让我们开始编写一个函数,它获取一个网页 URL 作为参数,请求该 URL 并返回标题元素的文本(见示例 17-1)。
首先,我们定义了一个名为 page_title 的函数,并用 async 关键字标记它。然后使用 trpl::get 函数抓取传入的 URL,再用 await 关键字等待响应。为了得到 response 的文本,我们调用它的 text 方法,并再次使用 await 进行等待。这两个步骤都是异步的。对于 get 函数来说,我们必须等待服务器先把响应的第一部分发回来,其中包括 HTTP headers、cookies 等,这些内容可以和响应体分开发送。尤其当响应体很大时,全部数据到达可能要花上一些时间。由于我们必须等待响应完整到达,text 方法自然也是 async 的。
我们必须显式地等待这两个 future,因为 Rust 中的 future 是 lazy 的:在你用 await 请求它之前,它什么都不会做。(实际上,如果你创建了 future 却不使用它,Rust 还会给出编译器警告。)这大概会让你想起第十三章[“使用迭代器处理元素序列”][iterators-lazy]中的讨论。迭代器只有在你调用 next 方法时才会工作,无论是直接调用,还是通过 for 循环,或者借助像 map 这样底层会调用 next 的方法。future 也是一样,只有你显式要求它运行时,它才会开始工作。这种惰性让 Rust 能够避免在真正需要之前就运行异步代码。
注意:这和我们在第十六章[“使用 spawn 创建新线程”][thread-spawn]里看到的
thread::spawn的行为不同,在那里我们传给新线程的闭包会立刻开始执行。它也和许多其他语言处理 async 的方式不同。但这对于 Rust 提供它一贯的性能保证很重要,正如迭代器也是如此。
有了 response_text 之后,我们就可以用 Html::parse 把它解析成 Html 类型的实例。这样一来,我们得到的就不再是原始字符串,而是一个可以把 HTML 当作更丰富数据结构来操作的类型。特别是,我们可以用 select_first 方法找到给定 CSS selector 的第一个匹配项。传入字符串 "title" 后,我们就能拿到文档中的第一个 <title> 元素,如果它存在的话。因为也可能根本没有匹配项,所以 select_first 返回的是 Option<ElementRef>。最后,我们使用 Option::map 方法:如果 Option 中有值,它就会对其中的值进行处理;如果没有,就什么都不做。(这里当然也可以使用 match 表达式,不过 map 更符合惯用写法。)在我们传给 map 的闭包里,会对 title 调用 inner_html 来获取其中的内容,它是一个 String。到这里,我们最终得到的就是一个 Option<String>。
注意,Rust 的 await 关键字放在要等待的表达式后面,而不是前面。也就是说,它是一个 postfix keyword(后缀关键字)。如果你在其他语言里用过 async,这一点可能和你的习惯不同;但在 Rust 中,这种设计会让链式方法调用更易读。因此,我们可以把 page_title 的函数体改写成在 trpl::get 和 text 调用之间插入 await 的链式写法,如示例 17-2 所示:
这样我们就成功编写了第一个异步函数!在我们向 main 加入一些代码调用它之前,让我们再多了解下我们写了什么以及它的意义。
当 Rust 遇到一个 async 关键字标记的代码块时,会将其编译为一个实现了 Future trait 的唯一的、匿名的数据类型。当 Rust 遇到一个被标记为 async 的函数时,会将其编译成一个函数体是异步代码块的非异步函数。异步函数的返回值类型是编译器为异步代码块所创建的匿名数据类型。
因此,编写 async fn 就等同于编写一个返回类型为 future 的函数。当编译器遇到类似示例 17-1 中 async fn page_title 的函数定义时,它等价于以下定义的非异步函数:
# extern crate trpl; // required for mdbook test
use std::future::Future;
use trpl::Html;
fn page_title(url: &str) -> impl Future<Output = Option<String>> {
async move {
let text = trpl::get(url).await.text().await;
Html::parse(&text)
.select_first("title")
.map(|title| title.inner_html())
}
}
让我们挨个看一下转换后版本的每一个部分:
- 它使用了之前第十章 [“trait 作为参数”][impl-trait] 部分讨论过的
impl Trait语法。 - 它返回的值实现了
Futuretrait,并且这个 trait 有一个关联类型Output。注意Output的类型是Option<String>,这和async fn版本的page_title的原始返回类型一致。 - 原始函数体中的所有代码都被包进了一个
async move块。回忆一下,代码块本身就是表达式。整个块就是函数返回的那个表达式。 - 如上所述,这个异步代码块产生一个
Option<String>类型的值。这个值与返回类型中的Output类型一致。这正类似于你已经见过的其它代码块。 - 这个新函数体之所以是
async move块,是由它使用url参数的方式决定的。(本章后面会更详细地讨论async和async move的区别。)
现在我们可以在 main 中调用 page_title。
使用运行时执行异步函数¶
首先,我们只获取单个页面的标题,如示例 17-3 所示。不幸的是,这段代码还不能编译。
<figcaption>示例 17-3:在 `main` 中通过一个用户提供的参数调用 `page_title` 函数</figcaption>
</figure>
我们沿用了第十二章[“接受命令行参数”][cli-args]一节中获取命令行参数的模式。然后把 URL 参数传给 `page_title`,再等待它的结果。由于 future 产出的值是 `Option<String>`,我们使用 `match` 表达式来根据页面是否含有 `<title>` 打印不同的信息。
唯一能使用 `await` 关键字的地方,是 async 函数或 async 代码块中,而 Rust 又不允许我们把特殊的 `main` 函数标记为 `async`。
<!-- manual-regeneration
cd listings/ch17-async-await/listing-17-03
cargo build
copy just the compiler error
-->
```text
error[E0752]: `main` function is not allowed to be `async`
--> src/main.rs:6:1
|
6 | async fn main() {
| ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`
<figcaption>示例 17-4:使用 `trpl::block_on` 等待一个 async 代码块</figcaption>
</figure>
当我们运行这段代码时,就会得到一开始期待的行为:
```console
$ cargo run -- https://www.rust-lang.org
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
Rust Programming Language
<figcaption>示例 17-5:对两个 URL 调用 `page_title`,看谁先返回</figcaption>
</figure>
我们首先分别对用户提供的两个 URL 调用 `page_title`。随后把得到的 future 保存到 `title_fut_1` 和 `title_fut_2` 中。记住,它们此时还什么都没做,因为 future 是惰性的,而我们也还没有等待它们。接着我们把这些 future 传给 `trpl::select`,它会返回一个值,用来表明传入的 future 中哪一个最先完成。
> 注意:在底层,`trpl::select` 建立在 `futures` crate 中更通用的 `select` 函数之上。`futures` crate 的 `select` 函数能做很多 `trpl::select` 做不到的事,不过它也带来了一些额外复杂性,所以我们暂时先跳过。
任意一个 future 都有可能“获胜”,因此这里返回 `Result` 并不合理。相反,`trpl::select` 返回的是一个我们之前还没见过的类型:`trpl::Either`。`Either` 在某种程度上有点像 `Result`,也有两个分支;但不同的是,它并没有内建“成功”或“失败”的语义,而是用 `Left` 和 `Right` 来表示“这个或那个”。
```rust
enum Either<A, B> {
Left(A),
Right(B),
}