Futures 和 Async 语法

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

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

async 关键字可以应用于代码块和函数,以指定它们可以被中断和恢复。在 async 代码块或 async 函数中,您可以使用 await 关键字等待 future 变为就绪,称为等待 future。在 async 代码块或函数中,您等待 future 的每个位置都是 async 代码块或函数可能被暂停和恢复的位置。

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

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

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

我们的第一个异步程序

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

  • futures crate 是 Rust 异步代码实验的官方场所,实际上也是 Future 类型最初设计的地方。

  • Tokio 是当今 Rust 中最广泛使用的异步运行时,尤其(但不仅限于!)对于 Web 应用程序。还有其他很棒的运行时,它们可能更适合您的目的。我们在 trpl 底层使用 Tokio,因为它很好且被广泛使用。

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

继续并将 trpl crate 添加到您的 hello-async 项目中

$ cargo add trpl

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

文件名: 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 元素

在代码清单 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_element 上的 inner_html 以获取其内容,这是一个 String。当一切都完成后,我们得到了一个 Option<String>

请注意,Rust 的 await 关键字位于您正在等待的表达式之后,而不是之前。也就是说,它是一个后缀关键字。如果您在其他语言中使用过异步,这可能与您习惯的不同。Rust 选择这样做是因为它使方法链更易于使用。因此,我们可以更改 page_title 的主体,以将 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())
    }
}
}

让我们逐步了解转换后的版本的每个部分

  • 它使用了我们在 “Trait 作为参数” 一节中讨论的 impl Trait 语法。
  • 返回的 trait 是 Future,其关联类型为 Output。请注意,Output 类型是 Option<String>,这与 page_titleasync fn 版本的原始返回类型相同。
  • 原始函数主体中调用的所有代码都包装在 async move 代码块中。请记住,代码块是表达式。整个代码块是从函数返回的表达式。
  • 如上所述,此异步代码块生成一个类型为 Option<String> 的值。该值与返回类型中的 Output 类型匹配。这就像您见过的其他代码块一样。
  • 新函数体是一个 async move 代码块,因为它使用了 name 参数。(稍后我们将在本章中更多地讨论 asyncasync move。)
  • 新版本的函数具有我们在输出类型中以前从未见过的生命周期类型:'_。由于该函数返回一个 Future,该 Future 引用了一个引用——在本例中,是来自 url 参数的引用——我们需要告诉 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`

原因是异步代码需要运行时:一个 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"
The title for https://rust-lang.net.cn was
            Rust Programming Language

呼:我们终于有了一些可用的异步代码!现在可以编译它了,我们可以运行它。选择几个 URL 并运行命令行工具。您可能会发现某些站点始终比其他站点更快,而在其他情况下,哪个站点“获胜”因运行而异。让我们简要地将注意力转回 future 的工作方式。

每个等待点——也就是说,代码使用 await 关键字的每个位置——都表示控制权被交还给运行时的位置。为了使其工作,Rust 需要跟踪异步代码块中涉及的状态,以便运行时可以启动其他一些工作,然后在准备好再次尝试推进它时返回。这是一个不可见的状态机,就像您编写了这样的代码

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
enum PageTitleFuture<'a> {
    GetAwaitPoint {
        url: &'a str,
    },
    TextAwaitPoint {
        response: trpl::Response,
    },
}
}

手动编写出来将是乏味且容易出错的,尤其是在以后更改代码时。相反,Rust 编译器会自动创建和管理异步代码的状态机数据结构。如果您想知道:是的,围绕数据结构的正常借用和所有权规则都适用。幸运的是,编译器还为我们处理检查这些规则,并提供了良好的错误消息。稍后我们将在本章中处理其中的一些错误!

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

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

注意:某些运行时提供宏,以便您可以编写异步 main 函数。这些宏将 async fn main() { ... } 重写为正常的 fn main,它执行与我们在代码清单 17-5 中手动执行的操作相同的操作:调用一个函数,该函数以 trpl::run 的方式运行 future 直至完成。

让我们将这些部分放在一起,看看我们如何编写并发代码,方法是使用从命令行传入的两个不同 URL 调用 page_title_for 并进行竞争。

文件名: 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

在代码清单 17-5 中,我们首先使用用户提供的两个 URL 调用 page_title。我们将调用 page_title 生成的 future 保存为 title_fut_1title_fut_2。请记住,这些 future 尚未执行任何操作,因为 future 是惰性的,并且我们尚未等待它们。然后我们将 future 传递给 trpl::race,它返回一个值以指示传递给它的 future 中哪个先完成。

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

您现在已经构建了一个小型的工作网络爬虫,我们可以将其扩展到许多不同的方向。更重要的是,您已经学习了使用 future 的基础知识,因此我们现在可以深入研究更多我们可以使用异步的事情。