Future 和异步语法

Rust 中异步编程的关键元素是 future 和 Rust 的 asyncawait 关键字。

A 未来 是一个可能现在还没有准备好,但在将来的某个时间点会准备好的值。(这个相同的概念在许多语言中都有出现,有时被称为“任务”或“承诺”。)Rust 提供了一个 Future 特性作为构建块,以便不同的异步操作可以用不同的数据结构实现,但具有共同的接口。在 Rust 中,我们说实现了 Future 特性的类型是未来。每个实现 Future 的类型都持有自己的关于已经取得的进展和“准备好”意味着什么的信息。

async 关键字可以应用于块和函数,以指定它们可以被中断和恢复。在 async 块或 async 函数内,您可以使用 await 关键字来等待一个未来值变得可用,这称为 等待未来值。在 async 块或函数中每个等待未来值的地方,都是该 async 块或函数可能被暂停和恢复的地方。与未来值检查其值是否已可用的过程称为 轮询

一些其他语言也使用 asyncawait 关键字进行异步编程。如果你对这些语言很熟悉,你可能会注意到 Rust 在处理这些关键字时有一些显著的不同,包括语法的处理。这是有原因的,我们将会看到!

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

这可能感觉有点抽象。让我们编写我们的第一个异步程序:一个小小的网页抓取器。我们将从命令行传入两个URL,同时获取它们,并返回最先完成的那个的结果。这个例子将包含相当多的新语法,但不要担心。我们将逐步解释你需要知道的一切。

我们的第一个异步程序

为了使本章专注于学习异步编程,而不是处理生态系统中的各个部分,我们创建了 trpl crate (trpl 是 “The Rust Programming Language” 的缩写)。它重新导出了你将需要的所有类型、特征和函数,主要来自 futurestokio crate。

  • futures crate 是 Rust 异步代码实验的官方场所,实际上 Future 类型最初就是在这里设计的。
  • Tokio 是当今 Rust 中使用最广泛的异步运行时,尤其是在(但不仅限于!)Web 应用程序中。还有其他优秀的运行时,它们可能更适合您的需求。我们在 trpl 中使用 Tokio 是因为它经过了充分测试并且广泛使用。

在某些情况下,trpl 也会重命名或包装原始 API,以便我们专注于与本章相关的细节。如果您想了解该 crate 的功能,我们鼓励您查看 其源代码。您将能够看到每个重新导出的 crate 来自哪里,我们还留下了大量注释解释该 crate 的功能。

创建一个名为 hello-async 的新二进制项目,并将 trpl crate 作为依赖项添加:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

现在我们可以使用trpl提供的各种组件来编写我们的第一个异步程序。我们将构建一个小的命令行工具,该工具获取两个网页,从每个网页中提取<title>元素,并打印出首先完成整个过程的网页的标题。

让我们从编写一个函数开始,该函数接受一个页面URL作为参数,向其发出请求,并返回标题元素的文本:

Filename: 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())
}
Listing 17-1: Defining an async function to get the title element from an HTML page

在清单 17-1 中,我们定义了一个名为 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 时看到的行为不同,当时我们传递给另一个线程的闭包会立即开始运行。这也与其他许多语言处理异步的方式不同!但对于 Rust 来说很重要。我们稍后会看到为什么这一点很重要。

一旦我们有了response_text,我们就可以使用Html::parse将其解析为Html类型的实例。现在我们有了一个可以用来处理HTML的更丰富的数据结构,而不仅仅是一个原始字符串。特别是,我们可以使用select_first方法来查找给定CSS选择器的第一个实例。通过传递字符串"title",我们将获得文档中的第一个<title>元素,如果有的话。因为可能没有匹配的元素,select_first返回一个Option<ElementRef>。最后,我们使用Option::map方法,这让我们可以在Option中存在项目时对其进行操作,如果不存在则什么都不做。(我们也可以在这里使用match表达式,但map更符合惯用法。)在我们提供给map的函数体中,我们调用title_elementinner_html方法来获取其内容,这是一个String。最终,我们得到了一个Option<String>

请注意,Rust 的 await 关键字放在你要等待的表达式之后,而不是之前。也就是说,它是一个 后缀关键字。这可能与你在其他语言中使用 async 时的习惯不同。Rust 选择这样做是因为它使得方法链的使用更加方便。因此,我们可以将 page_url_for 的主体改为将 trpl::gettext 函数调用用 await 连接起来,如示例 17-2 所示:

Filename: 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())
}
Listing 17-2: Chaining with the await keyword

至此,我们成功编写了我们的第一个异步函数!在我们在 main 中添加一些代码来调用它之前,让我们再多谈谈我们编写的内容及其意义。

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

因此,编写 async fn 相当于编写一个返回 未来 类型的函数。当编译器看到如清单 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 语法。
  • 返回的特质是一个 Future,具有一个关联类型 Output。注意 Output 类型是 Option<String>,这与 async fn 版本的 page_title 的原始返回类型相同。
  • 在原始函数体中调用的所有代码都被包装在一个 async move 块中。记住,块是表达式。这个整个块就是从函数返回的表达式。
  • 这个异步块生成一个类型为Option<String>的值,如上所述。该值与返回类型中的Output类型匹配。这与其他你见过的块是一样的。
  • 新的函数体是一个 async move 块,因为它是如何使用 url 参数的。(我们将在本章后面更详细地讨论 asyncasync move。)
  • 新版本的函数在输出类型中有一个我们以前没见过的生命周期:'_。因为函数返回一个引用了引用的Future——在这种情况下,是从url参数来的引用——我们需要告诉Rust我们希望包含这个引用。虽然我们在这里不需要命名生命周期,因为Rust足够聪明,知道只有一个引用可能涉及,但我们确实需要明确指出,结果的Future受该生命周期的约束。

现在我们可以在 main 中调用 page_title。首先,我们只获取单个页面的标题。在清单 17-3 中,我们遵循了第 12 章中用于获取命令行参数的相同模式。然后我们将第一个 URL 传递给 page_title,并等待结果。因为未来产生的值是一个 Option<String>,我们使用一个 match 表达式来打印不同的消息,以考虑页面是否有 <title>

Filename: 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())
}
Listing 17-3: Calling the page_title function from main with a user-supplied argument

不幸的是,这无法编译。我们只能在异步函数或块中使用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 库。程序的 main 函数可以 初始化 一个运行时,但它本身并不是一个运行时。(我们稍后会详细讨论为什么这一点很重要。)每个执行异步代码的 Rust 程序至少有一个地方会设置运行时并执行未来的任务。

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

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

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

Filename: 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())
}
Listing 17-4: Awaiting an async block with trpl::run

当我们运行这个时,我们得到了最初可能预期的行为:

$ 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

Phew: 我们终于有一些可以工作的异步代码了!这现在可以编译,我们可以运行它。在我们添加代码来竞速两个站点之前,让我们简要回顾一下未来的运作方式。

每个 await 点——也就是说,代码中每个使用 await 关键字的地方——都表示一个将控制权交还给运行时的地方。为了使这一点生效,Rust 需要跟踪异步块中涉及的状态,以便运行时可以启动其他工作,然后在准备好再次尝试推进这个任务时返回。这就像你以这种方式编写一个枚举来在每个 await 点保存当前状态的不可见状态机:

#![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 返回的未来状态机,但 main 是程序的起点!相反,我们在 main 中调用 trpl::run 函数,它设置了一个运行时并运行由 async 块返回的未来,直到它返回 Ready

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

让我们把这些部分放在一起,看看我们如何通过调用page_title并从命令行传入两个不同的URL来编写并发代码,并进行竞速。

Filename: 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)
}
Listing 17-5:

在清单 17-5 中,我们首先为每个用户提供的 URL 调用 page_title。我们将调用 page_title 产生的未来值保存为 title_fut_1title_fut_2。记住,这些还不会做任何事情,因为未来值是惰性的,我们还没有等待它们。然后我们将未来值传递给 trpl::race,它返回一个值来指示传递给它的未来值中哪一个先完成。

注意:在底层,race 是基于一个更通用的函数 select 构建的, 在实际的 Rust 代码中,你将更频繁地遇到 selectselect 函数可以做很多 trpl::race 函数做不到的事情,但它也 有一些我们目前可以忽略的额外复杂性。

任一 future 都可以合法地“获胜”,因此返回一个 Result 没有意义。相反,race 返回一个我们之前未见过的类型,trpl::EitherEither 类型在某种程度上类似于 Result,因为它也有两种情况。然而,Either 并没有像 Result 那样内置成功或失败的概念。相反,它使用 LeftRight 来表示“其中之一”。

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

race 函数如果第一个参数先完成,则返回 Left,并带有该未来的输出;如果 那个 一个先完成,则返回带有第二个未来参数输出的 Right。这与调用函数时参数出现的顺序相匹配:第一个参数位于第二个参数的左侧。

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

你现在已经构建了一个小型的可工作的网络爬虫!选择几个URL并运行命令行工具。你可能会发现有些网站比其他网站更稳定地快,而在其他情况下,哪个网站“获胜”则会因运行而异。更重要的是,你已经学会了使用future的基础知识,因此我们现在可以深入探讨使用async可以做的更多事情。