Futures 和 Async 语法
Rust 中异步编程的关键元素是 futures 和 Rust 的 async
和 await
关键字。
一个 future 是一个可能现在还没有准备好,但在未来的某个时刻会准备好的值。(这个概念在许多语言中都有出现,有时使用其他名称,如 task 或 promise。)Rust 提供了一个 Future
trait 作为构建块,以便不同的异步操作可以使用不同的数据结构实现,但具有共同的接口。在 Rust 中,futures 是实现 Future
trait 的类型。每个 future 都持有关于已经取得的进展以及“准备好”意味着什么的信息。
你可以将 async
关键字应用于代码块和函数,以指定它们可以被中断和恢复。在 async 块或 async 函数中,你可以使用 await
关键字来 等待一个 future(即等待它变为准备好)。在 async 块或函数中等待 future 的任何地方都是该 async 块或函数可以暂停和恢复的潜在点。检查 future 以查看其值是否可用的过程称为 轮询。
其他一些语言,如 C# 和 JavaScript,也使用 async
和 await
关键字进行异步编程。如果你熟悉这些语言,你可能会注意到 Rust 在处理方式上的一些显著差异,包括它如何处理语法。这是有充分理由的,我们稍后会看到!
在编写异步 Rust 时,我们大多数时候使用 async
和 await
关键字。Rust 将它们编译成使用 Future
trait 的等效代码,就像它将 for
循环编译成使用 Iterator
trait 的等效代码一样。由于 Rust 提供了 Future
trait,你还可以在需要时为自己的数据类型实现它。我们将在本章中看到的许多函数返回具有自己 Future
实现的类型。我们将在本章末尾回到 trait 的定义,并深入探讨它的工作原理,但这些细节足以让我们继续前进。
这一切可能感觉有点抽象,所以让我们编写我们的第一个异步程序:一个小型网页抓取器。我们将从命令行传入两个 URL,并发地获取它们,并返回先完成的结果。这个例子会有一些新的语法,但不用担心——我们会逐步解释你需要知道的一切。
我们的第一个异步程序
为了将本章的重点放在学习异步而不是处理生态系统的各个部分上,我们创建了 trpl
crate(trpl
是“The Rust Programming Language”的缩写)。它重新导出了你需要的所有类型、trait 和函数,主要来自 futures
和 tokio
crate。futures
crate 是 Rust 异步代码实验的官方场所,实际上 Future
trait 最初就是在这里设计的。Tokio 是当今 Rust 中最广泛使用的异步运行时,尤其是对于 Web 应用程序。还有其他优秀的运行时,它们可能更适合你的需求。我们在 trpl
中使用 tokio
crate,因为它经过了良好的测试并被广泛使用。
在某些情况下,trpl
还会重命名或包装原始 API,以让你专注于本章相关的细节。如果你想了解 crate 的功能,我们鼓励你查看 其源代码。你将能够看到每个重新导出的 crate 来自哪里,并且我们留下了大量注释来解释 crate 的功能。
创建一个名为 hello-async
的新二进制项目,并将 trpl
crate 添加为依赖项:
$ cargo new hello-async
$ cd hello-async
$ cargo add trpl
现在我们可以使用 trpl
提供的各种组件来编写我们的第一个异步程序。我们将构建一个小型命令行工具,它获取两个网页,从每个网页中提取 <title>
元素,并打印出先完成整个过程的页面的标题。
定义 page_title 函数
让我们首先编写一个函数,它接受一个页面 URL 作为参数,向它发出请求,并返回标题元素的文本(见 Listing 17-1)。
extern crate trpl; // required for mdbook test fn main() { // TODO: we'll add this next! } use trpl::Html; async fn page_title(url: &str) -> Option<String> { let response = trpl::get(url).await; let response_text = response.text().await; Html::parse(&response_text) .select_first("title") .map(|title_element| title_element.inner_html()) }
首先,我们定义一个名为 page_title
的函数,并用 async
关键字标记它。然后我们使用 trpl::get
函数获取传入的 URL,并使用 await
关键字等待响应。为了获取响应的文本,我们调用它的 text
方法,并再次使用 await
关键字等待它。这两个步骤都是异步的。对于 get
函数,我们必须等待服务器发送回其响应的第一部分,其中包括 HTTP 头、cookie 等,并且可以与响应体分开传递。特别是如果响应体非常大,可能需要一些时间才能全部到达。因为我们必须等待 整个 响应到达,所以 text
方法也是异步的。
我们必须显式地等待这两个 futures,因为 Rust 中的 futures 是 惰性 的:它们在你使用 await
关键字要求它们之前不会做任何事情。(事实上,如果你不使用 future,Rust 会显示一个编译器警告。)这可能会让你想起第 13 章中关于迭代器的讨论,在 使用迭代器处理一系列项目 部分。迭代器在你调用它们的 next
方法之前不会做任何事情——无论是直接调用还是通过使用 for
循环或 map
等方法间接调用。同样,futures 在你明确要求它们之前也不会做任何事情。这种惰性允许 Rust 避免在真正需要之前运行异步代码。
注意:这与我们在前一章中使用
thread::spawn
在 使用 spawn 创建新线程 中看到的行为不同,在那里我们传递给另一个线程的闭包立即开始运行。这也与许多其他语言处理异步的方式不同。但对于 Rust 来说,能够提供其性能保证是很重要的,就像它对迭代器所做的那样。
一旦我们有了 response_text
,我们就可以使用 Html::parse
将其解析为 Html
类型的实例。现在我们有了一个数据类型,可以用来将 HTML 作为更丰富的数据结构进行处理。特别是,我们可以使用 select_first
方法来查找给定 CSS 选择器的第一个实例。通过传递字符串 "title"
,我们将获得文档中的第一个 <title>
元素(如果有的话)。因为可能没有任何匹配的元素,select_first
返回一个 Option<ElementRef>
。最后,我们使用 Option::map
方法,它允许我们在 Option
中有项目时对其进行处理,如果没有则不做任何事情。(我们也可以在这里使用 match
表达式,但 map
更符合习惯。)在我们提供给 map
的函数体中,我们调用 title_element
的 inner_html
来获取其内容,这是一个 String
。当一切完成后,我们得到了一个 Option<String>
。
请注意,Rust 的 await
关键字位于你正在等待的表达式 之后,而不是之前。也就是说,它是一个 后缀 关键字。如果你在其他语言中使用过 async
,这可能会与你习惯的不同,但在 Rust 中,它使得方法链更加容易使用。因此,我们可以将 page_url_for
的函数体更改为将 trpl::get
和 text
函数调用与 await
链接在一起,如 Listing 17-2 所示。
extern crate trpl; // required for mdbook test use trpl::Html; fn main() { // TODO: we'll add this next! } async fn page_title(url: &str) -> Option<String> { let response_text = trpl::get(url).await.text().await; Html::parse(&response_text) .select_first("title") .map(|title_element| title_element.inner_html()) }
这样,我们就成功编写了我们的第一个异步函数!在我们添加一些代码到 main
中调用它之前,让我们再讨论一下我们编写的内容及其含义。
当 Rust 看到一个用 async
关键字标记的块时,它会将其编译成一个独特的、匿名的数据类型,该类型实现了 Future
trait。当 Rust 看到一个用 async
标记的函数时,它会将其编译成一个非异步函数,其主体是一个异步块。异步函数的返回类型是编译器为该异步块创建的匿名数据类型的类型。
因此,编写 async fn
等同于编写一个返回 future 的函数。对于编译器来说,像 Listing 17-1 中的 async fn page_title
这样的函数定义等同于一个非异步函数,定义如下:
#![allow(unused)] fn main() { 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()) } } }
让我们逐步了解转换后的版本的每个部分:
- 它使用了我们在第 10 章 “Traits as Parameters” 部分讨论的
impl Trait
语法。 - 返回的 trait 是一个
Future
,其关联类型为Output
。请注意,Output
类型是Option<String>
,这与async fn
版本的page_title
的原始返回类型相同。 - 原始函数体中调用的所有代码都包装在一个
async move
块中。记住,块是表达式。这个整个块是从函数返回的表达式。 - 这个异步块生成一个类型为
Option<String>
的值,如前所述。该值与返回类型中的Output
类型匹配。这就像你见过的其他块一样。 - 新的函数体是一个
async move
块,因为它使用了url
参数。(我们将在本章后面更多地讨论async
与async move
。)
现在我们可以在 main
中调用 page_title
。
确定单个页面的标题
首先,我们只获取一个页面的标题。在 Listing 17-3 中,我们遵循与第 12 章中相同的模式,在 接受命令行参数 部分获取命令行参数。然后我们将第一个 URL 传递给 page_title
并等待结果。因为 future 生成的值是一个 Option<String>
,所以我们使用 match
表达式来打印不同的消息,以考虑页面是否有 <title>
。
extern crate trpl; // required for mdbook test
use trpl::Html;
async fn main() {
let args: Vec<String> = std::env::args().collect();
let url = &args[1];
match page_title(url).await {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
}
async fn page_title(url: &str) -> Option<String> {
let response_text = trpl::get(url).await.text().await;
Html::parse(&response_text)
.select_first("title")
.map(|title_element| title_element.inner_html())
}
不幸的是,这段代码无法编译。我们唯一可以使用 await
关键字的地方是在异步函数或块中,而 Rust 不允许我们将特殊的 main
函数标记为 async
。
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`
main
不能标记为 async
的原因是异步代码需要一个 运行时:一个管理异步代码执行细节的 Rust crate。程序的 main
函数可以 初始化 一个运行时,但它本身不是一个运行时。(我们稍后会看到更多关于为什么是这样的原因。)每个执行异步代码的 Rust 程序至少有一个地方设置运行时并执行 futures。
大多数支持异步的语言都捆绑了一个运行时,但 Rust 没有。相反,有许多不同的异步运行时可用,每个运行时都根据其目标用例做出不同的权衡。例如,具有许多 CPU 核心和大量 RAM 的高吞吐量 Web 服务器与具有单核、少量 RAM 且没有堆分配能力的微控制器有着非常不同的需求。提供这些运行时的 crate 通常还提供常见功能的异步版本,如文件或网络 I/O。
在这里,以及本章的其余部分,我们将使用 trpl
crate 中的 run
函数,它接受一个 future 作为参数并运行它直到完成。在幕后,调用 run
会设置一个运行时,用于运行传入的 future。一旦 future 完成,run
返回 future 生成的任何值。
我们可以直接将 page_title
返回的 future 传递给 run
,一旦它完成,我们就可以匹配生成的 Option<String>
,就像我们在 Listing 17-3 中尝试做的那样。然而,对于本章中的大多数示例(以及现实世界中的大多数异步代码),我们将不仅仅做一个异步函数调用,所以我们将传递一个 async
块并显式地等待 page_title
调用的结果,如 Listing 17-4 所示。
extern crate trpl; // required for mdbook test
use trpl::Html;
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::run(async {
let url = &args[1];
match page_title(url).await {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
})
}
async fn page_title(url: &str) -> Option<String> {
let response_text = trpl::get(url).await.text().await;
Html::parse(&response_text)
.select_first("title")
.map(|title_element| title_element.inner_html())
}
当我们运行这段代码时,我们得到了最初预期的行为:
$ 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
终于,我们有了一些可以工作的异步代码!但在我们添加代码来让两个站点竞争之前,让我们简要地回到 futures 的工作原理。
每个 等待点——即代码使用 await
关键字的地方——代表一个将控制权交还给运行时的位置。为了实现这一点,Rust 需要跟踪异步块中涉及的状态,以便运行时可以启动其他工作,然后在准备好时返回并尝试推进第一个工作。这是一个不可见的状态机,就像你编写了一个枚举来在每个等待点保存当前状态一样:
#![allow(unused)] fn main() { extern crate trpl; // required for mdbook test enum PageTitleFuture<'a> { Initial { url: &'a str }, GetAwaitPoint { url: &'a str }, TextAwaitPoint { response: trpl::Response }, } }
手动编写代码来在每种状态之间转换将是繁琐且容易出错的,特别是当你需要添加更多功能和更多状态时。幸运的是,Rust 编译器为异步代码自动创建并管理状态机数据结构。围绕数据结构的正常借用和所有权规则仍然适用,幸运的是,编译器还为我们处理这些检查并提供有用的错误消息。我们将在本章后面处理其中的一些。
最终,必须有东西执行这个状态机,而这个东西就是运行时。(这就是为什么你在查看运行时时可能会遇到 执行器 的引用:执行器是运行时负责执行异步代码的部分。)
现在你可以看到为什么编译器在 Listing 17-3 中阻止我们将 main
本身变成一个异步函数。如果 main
是一个异步函数,那么其他东西需要管理 main
返回的 future 的状态机,但 main
是程序的起点!相反,我们在 main
中调用 trpl::run
函数来设置一个运行时,并运行 async
块返回的 future 直到它完成。
注意:一些运行时提供了宏,因此你可以 编写 一个异步
main
函数。这些宏将async fn main() { ... }
重写为一个普通的fn main
,它执行与我们在 Listing 17-5 中手动执行的相同操作:调用一个函数,以trpl::run
的方式运行一个 future 直到完成。
现在让我们将这些部分放在一起,看看如何编写并发代码。
让我们的两个 URL 竞争
在 Listing 17-5 中,我们调用 page_title
并传入从命令行传入的两个不同 URL,让它们竞争。
extern crate trpl; // required for mdbook test
use trpl::{Either, Html};
fn main() {
let args: Vec<String> = std::env::args().collect();
trpl::run(async {
let title_fut_1 = page_title(&args[1]);
let title_fut_2 = page_title(&args[2]);
let (url, maybe_title) =
match trpl::race(title_fut_1, title_fut_2).await {
Either::Left(left) => left,
Either::Right(right) => right,
};
println!("{url} returned first");
match maybe_title {
Some(title) => println!("Its page title is: '{title}'"),
None => println!("Its title could not be parsed."),
}
})
}
async fn page_title(url: &str) -> (&str, Option<String>) {
let text = trpl::get(url).await.text().await;
let title = Html::parse(&text)
.select_first("title")
.map(|title| title.inner_html());
(url, title)
}
我们首先为每个用户提供的 URL 调用 page_title
。我们将生成的 futures 保存为 title_fut_1
和 title_fut_2
。记住,这些 futures 现在还没有做任何事情,因为 futures 是惰性的,我们还没有等待它们。然后我们将 futures 传递给 trpl::race
,它返回一个值来指示传递给它的 futures 中哪个先完成。
注意:在幕后,
race
是建立在更通用的函数select
之上的,你会在现实世界的 Rust 代码中更频繁地遇到它。select
函数可以做很多trpl::race
函数无法做到的事情,但它也有一些我们目前可以跳过的额外复杂性。
任何一个 future 都可以合法地“获胜”,所以返回一个 Result
是没有意义的。相反,race
返回一个我们以前没有见过的类型,trpl::Either
。Either
类型与 Result
有些相似,因为它有两个情况。与 Result
不同的是,Either
中没有成功或失败的概念。相反,它使用 Left
和 Right
来表示“一个或另一个”:
#![allow(unused)] fn main() { enum Either<A, B> { Left(A), Right(B), } }
如果第一个参数获胜,race
函数返回 Left
并带有该 future 的输出;如果第二个 future 参数获胜,则返回 Right
并带有该 future 的输出。这与调用函数时参数的顺序相匹配:第一个参数在第二个参数的左边。
我们还更新了 page_title
以返回传入的相同 URL。这样,如果先返回的页面没有可解析的 <title>
,我们仍然可以打印一个有意义的消息。有了这些信息,我们通过更新 println!
输出来指示哪个 URL 先完成以及该网页的 <title>
(如果有的话)。
你现在已经构建了一个小型的工作网页抓取器!选择几个 URL 并运行命令行工具。你可能会发现某些站点始终比其他站点更快,而在其他情况下,较快的站点在每次运行时都会有所不同。更重要的是,你已经学会了使用 futures 的基础知识,所以现在我们可以更深入地探讨异步编程的更多内容。