Future 和异步语法

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

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

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

一些其他语言,如 C# 和 JavaScript,也使用 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 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作为参数,向其发出请求,并返回标题元素的文本(参见清单17-1)。

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

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

现在我们可以在 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,一旦它完成,我们就可以匹配返回的 Option<String>,就像我们在清单 17-3 中尝试做的那样。然而,对于本章中的大多数示例(以及现实世界中的大多数异步代码),我们将不仅仅进行一次异步函数调用,因此我们将传递一个 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

呼—我们终于有一些可以工作的异步代码了!但在我们添加代码来让两个站点相互竞争之前,让我们简要回顾一下未来对象是如何工作的。

每个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 那样。

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

将我们的两个URL进行竞速测试

在清单 17-5 中,我们使用从命令行传递的两个不同 URL 调用 page_title 并对它们进行竞速。

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:

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

注意:在底层,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并运行命令行工具。你可能会发现有些网站始终比其他网站快,而在其他情况下,较快的网站则会随着运行而变化。更重要的是,你已经学会了使用futures的基础知识,现在我们可以更深入地探讨我们可以用async做些什么。