Futures 和 Async 语法

Rust 中异步编程的关键要素是 futures 和 Rust 的 asyncawait 关键字。

future 是一个值,它可能现在还未就绪,但将来某个时候会就绪。(相同的概念在许多语言中都有出现,有时以其他名称出现,例如 taskpromise。)Rust 提供了一个 Future trait 作为构建块,以便可以使用不同的数据结构实现不同的异步操作,但具有通用的接口。在 Rust 中,future 是实现了 Future trait 的类型。每个 future 都保存着关于已取得的进展以及 “就绪” 意味着什么的信息。

您可以将 async 关键字应用于代码块和函数,以指定它们可以被中断和恢复。在 async 代码块或 async 函数中,您可以使用 await 关键字来等待 future(即,等待它变为就绪)。在 async 代码块或函数中,您等待 future 的任何点都是该 async 代码块或函数暂停和恢复的潜在位置。检查 future 以查看其值是否可用的过程称为轮询polling)。

一些其他语言,例如 C# 和 JavaScript,也使用 asyncawait 关键字进行异步编程。如果您熟悉这些语言,您可能会注意到 Rust 在处理异步方面的一些显着差异,包括它如何处理语法。这是有充分理由的,我们稍后会看到!

在编写异步 Rust 代码时,我们大多数时候都使用 asyncawait 关键字。Rust 将它们编译为使用 Future trait 的等效代码,就像它将 for 循环编译为使用 Iterator trait 的等效代码一样。但是,由于 Rust 提供了 Future trait,您也可以在需要时为自己的数据类型实现它。我们在本章中将看到的许多函数都会返回具有其自身 Future 实现的类型。我们将在本章末尾回到 trait 的定义,并深入探讨其工作原理的更多细节,但这足以让我们继续前进。

这一切可能感觉有点抽象,所以让我们编写我们的第一个异步程序:一个小型的网络爬虫。我们将从命令行传入两个 URL,并发地获取它们,并返回先完成的那个的结果。这个例子将包含相当多的新语法,但不用担心——我们将解释您需要知道的一切。

我们的第一个异步程序

为了使本章的重点放在学习异步而不是处理生态系统的各个部分,我们创建了 trpl crate(trpl 是 “The Rust Programming Language” 的缩写)。它重新导出了您需要的所有类型、trait 和函数,主要来自 futurestokiocrate。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 作为参数,向其发出请求,并返回 title 元素的文本(参见列表 17-1)。

文件名:src/main.rs
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()) }
列表 17-1:定义一个异步函数来获取 HTML 页面的 title 元素

首先,我们定义一个名为 page_title 的函数,并使用 async 关键字标记它。然后我们使用 trpl::get 函数来获取传入的任何 URL,并添加 await 关键字来等待响应。为了获取响应的文本,我们调用其 text 方法,并再次使用 await 关键字等待它。这两个步骤都是异步的。对于 get 函数,我们必须等待服务器发回其响应的第一部分,其中将包括 HTTP 标头、cookie 等,并且可以与响应主体分开传送。特别是如果主体非常大,则可能需要一段时间才能全部到达。因为我们必须等待整个响应到达,所以 text 方法也是异步的。

我们必须显式地等待这两个 future,因为 Rust 中的 future 是惰性的:在您使用 await 关键字要求它们执行之前,它们不会做任何事情。(事实上,如果您不使用 future,Rust 会显示编译器警告。)这可能会让您想起第 13 章中关于迭代器的讨论,在 使用迭代器处理项目序列部分中。迭代器除非您调用它们的 next 方法(无论是直接调用还是通过使用 for 循环或诸如 map 之类在底层使用 next 的方法),否则什么也不做。同样,future 除非您显式地要求它们执行,否则什么也不做。这种惰性允许 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::gettext 函数调用与它们之间的 await 链接在一起,如列表 17-2 所示。

文件名:src/main.rs
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()) }
列表 17-2:使用 await 关键字进行链接

有了这个,我们已经成功地编写了我们的第一个异步函数!在我们向 main 中添加一些代码来调用它之前,让我们更多地谈谈我们编写的内容以及它的含义。

当 Rust 看到用 async 关键字标记的代码块时,它会将其编译为实现 Future trait 的唯一匿名数据类型。当 Rust 看到用 async 标记的函数时,它会将其编译为非异步函数,该函数的正文是一个异步代码块。异步函数的返回类型是编译器为该异步代码块创建的匿名数据类型的类型。

因此,编写 async fn 等效于编写一个返回返回类型的 future 的函数。对于编译器来说,像列表 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 章 “Trait 作为参数”部分中讨论的 impl Trait 语法。
  • 返回的 trait 是一个 Future,其关联类型为 Output。请注意,Output 类型是 Option<String>,这与 page_titleasync fn 版本的原始返回类型相同。
  • 原始函数体中调用的所有代码都包装在 async move 代码块中。请记住,代码块是表达式。整个代码块是从函数返回的表达式。
  • 正如刚刚描述的那样,此异步代码块生成一个类型为 Option<String> 的值。该值与返回类型中的 Output 类型匹配。这就像您见过的其他代码块一样。
  • 新函数体是一个 async move 代码块,因为它使用了 url 参数。(我们将在本章后面详细讨论 asyncasync move 的区别。)
  • 新版本的函数具有我们在输出类型中以前从未见过的生命周期:'_。因为该函数返回一个引用——在本例中是来自 url 参数的引用——的 future,所以我们需要告诉 Rust 我们希望包含该引用。我们不必在这里命名生命周期,因为 Rust 非常聪明,知道只有一个引用可能涉及,但我们确实必须明确声明生成的 future 受该生命周期约束。

现在我们可以在 main 中调用 page_title

确定单个页面的标题

首先,我们将只获取单个页面的标题。在列表 17-3 中,我们遵循了我们在第 12 章中使用的相同模式,以在 接受命令行参数部分中获取命令行参数。然后我们将第一个 URL 传递给 page_title 并等待结果。由于 future 生成的值是 Option<String>,我们使用 match 表达式来打印不同的消息,以说明页面是否具有 <title>

文件名:src/main.rs
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()) }
列表 17-3:从 main 中使用用户提供的参数调用 page_title 函数

不幸的是,此代码无法编译。我们可以使用 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 程序都至少有一个地方设置运行时并执行 future。

大多数支持异步的语言都捆绑了运行时,但 Rust 没有。相反,有许多不同的异步运行时可用,每个运行时都针对其目标用例进行了不同的权衡。例如,具有多个 CPU 核心和大量 RAM 的高吞吐量 Web 服务器的需求与具有单核、少量 RAM 且没有堆分配能力的微控制器的需求截然不同。提供这些运行时的 crate 通常还提供常见功能的异步版本,例如文件或网络 I/O。

在这里,以及在本章的其余部分,我们将使用 trpl crate 中的 run 函数,该函数将 future 作为参数并运行它直到完成。在幕后,调用 run 会设置一个运行时,用于运行传入的 future。一旦 future 完成,run 将返回 future 生成的任何值。

我们可以将 page_title 返回的 future 直接传递给 run,一旦它完成,我们可以匹配生成的 Option<String>,就像我们在列表 17-3 中尝试做的那样。但是,对于本章中的大多数示例(以及现实世界中的大多数异步代码),我们将做的不止一个异步函数调用,因此我们将传递一个 async 代码块并显式地等待 page_title 调用的结果,如列表 17-4 所示。

文件名:src/main.rs
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()) }
列表 17-4:使用 trpl::run 等待异步代码块

当我们运行此代码时,我们得到了最初预期的行为

$ cargo run -- https://rust-lang.net.cn Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s Running `target/debug/async_await 'https://rust-lang.net.cn'` The title for https://rust-lang.net.cn was Rust Programming Language

呼——我们终于有了一些可用的异步代码!但是在我们添加代码来让两个站点相互竞争之前,让我们简要地将注意力转回到 future 的工作方式。

每个等待点——也就是说,代码使用 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 编译器会自动创建和管理异步代码的状态机数据结构。围绕数据结构的正常借用和所有权规则仍然适用,令人高兴的是,编译器还为我们处理了这些检查并提供了有用的错误消息。我们将在本章后面详细介绍其中的一些内容。

最终,必须有某种东西来执行此状态机,而该东西就是运行时。(这就是为什么当您研究运行时时可能会遇到对执行器的引用:执行器是运行时中负责执行异步代码的部分。)

现在您可以看到为什么编译器在列表 17-3 中阻止我们将 main 本身设为异步函数。如果 main 是一个异步函数,则需要其他东西来管理 main 返回的任何 future 的状态机,但 main 是程序的起点!相反,我们在 main 中调用了 trpl::run 函数来设置运行时并运行异步代码块返回的 future,直到它返回 Ready

注意:一些运行时提供宏,因此您可以编写异步 main 函数。这些宏将 async fn main() { ... } 重写为普通的 fn main,它执行与我们在列表 17-5 中手动执行的操作相同的操作:调用一个函数来运行 future 直到完成,就像 trpl::run 所做的那样。

现在让我们将这些部分放在一起,看看我们如何编写并发代码。

让我们的两个 URL 相互竞争

在列表 17-5 中,我们使用从命令行传入的两个不同的 URL 调用 page_title 并让它们竞争。

文件名:src/main.rs
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) }
列表 17-5

我们首先为每个用户提供的 URL 调用 page_title。我们将生成的 future 保存为 title_fut_1title_fut_2。请记住,这些操作目前什么也不做,因为 future 是惰性的,并且我们尚未等待它们。然后我们将 future 传递给 trpl::race,它返回一个值来指示传递给它的 future 中哪个先完成。

注意:在底层,race 构建在更通用的函数 select 之上,您将在现实世界的 Rust 代码中更频繁地遇到它。select 函数可以做很多 trpl::race 函数无法做的事情,但它也具有我们现在可以跳过的一些额外复杂性。

任何 future 都可以合理地 “获胜”,因此返回 Result 没有意义。相反,race 返回一个我们以前从未见过的类型 trpl::EitherEither 类型在某种程度上类似于 Result,因为它有两种情况。但是,与 Result 不同,Either 中没有内置成功或失败的概念。相反,它使用 LeftRight 来表示 “两者之一”

#![allow(unused)] fn main() { enum Either<A, B> { Left(A), Right(B), } }

如果第一个参数获胜,则 race 函数返回 Left 以及该 future 的输出,如果那个参数获胜,则返回 Right 以及第二个 future 参数的输出。这与调用函数时参数出现的顺序相匹配:第一个参数位于第二个参数的左侧。

我们还更新了 page_title 以返回传入的相同 URL。这样,如果首先返回的页面没有我们可以解析的 <title>,我们仍然可以打印有意义的消息。有了这些可用信息,我们最后更新了我们的 println! 输出,以指示哪个 URL 首先完成,以及该 URL 的网页的 <title> 是什么(如果有)。

您已经构建了一个小型的工作网络爬虫!选择几个 URL 并运行命令行工具。您可能会发现某些站点始终比其他站点快,而在其他情况下,更快的站点因运行而异。更重要的是,您已经学习了使用 future 的基础知识,因此现在我们可以更深入地研究我们可以使用异步做什么。