键盘快捷键

按 ← 或 → 在章节之间导航

按 S 或 / 在书中搜索

按 ? 显示此帮助

按 Esc 隐藏此帮助

Futures 与异步语法

Rust 中异步编程的关键元素是 futures 以及 Rust 的 async 和 await 关键字。

future 是一个目前可能尚未就绪,但将来某个时刻会变得就绪的值。 (同样的概念也出现在许多语言中,有时以其他名称出现,如 task 或 promise。) Rust 提供了一个 Future trait 作为构建块,这样不同的异步操作 可以用不同的数据结构实现,但共享一个通用接口。在 Rust 中, futures 是实现 Future trait 的类型。每个 future 都保存着 自身关于已取得的进展以及「就绪」意味着什么的内部信息。

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

一些其他语言,如 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 crate 和 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 为参数,向其发起请求, 并返回 <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| title.inner_html())
}
代码清单 17-1:定义一个异步函数来获取 HTML 页面的标题元素

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

我们必须显式地等待这两个 futures,因为 Rust 中的 futures 是惰性的(lazy): 在你用 await 关键字要求它们做事之前,它们不会做任何事。(事实上, 如果你没有使用一个 future,Rust 会显示编译器警告。)这可能会让你想起 第 13 章「用迭代器处理一系列项」一节中对迭代器的讨论。 迭代器在你调用其 next 方法之前什么也不做——无论是直接调用, 还是通过 for 循环或 map 这类在底层使用 next 的方法。 同样,futures 在你显式要求之前也什么也不做。这种惰性让 Rust 能够 避免在实际需要之前运行异步代码。

注意:这不同于我们在第 16 章「使用 spawn 创建新线程」 一节中使用 thread::spawn 时看到的行为,在那里我们传递给另一个 线程的闭包会立即开始运行。这也不同于许多其他语言处理异步的方式。 但这对 Rust 能够提供其性能保证非常重要,就像迭代器的情况一样。

一旦有了 response_text,我们就可以使用 Html::parse 将其解析为 Html 类型的实例。 现在我们拥有的不再是原始字符串,而是一个可以用来将 HTML 作为更丰富的 数据结构来处理的数据类型。具体来说,我们可以使用 select_first 方法 来查找给定 CSS 选择器的第一个实例。通过传入字符串 "title", 我们将得到文档中第一个 <title> 元素(如果有的话)。 因为可能没有任何匹配的元素,所以 select_first 返回 Option<ElementRef>。 最后,我们使用 Option::map 方法,它让我们能够在 Option 中有值时处理其中的项, 没有值时什么也不做。(这里我们也可以使用 match 表达式,但 map 更符合 Rust 风格。) 在提供给 map 的函数体中,我们对 title 调用 inner_html 来获取其内容, 这是一个 String。全部完成后,我们得到一个 Option<String>。

请注意,Rust 的 await 关键字位于你所等待的表达式的后面, 而不是前面。也就是说,它是一个后缀关键字。如果你在其他语言中使用过 async,这可能与你习惯的不同,但在 Rust 中,这使得方法链的编写 更加优雅。因此,我们可以将 page_title 的函数体改为用 await 将 trpl::get 和 text 函数调用链接在一起,如代码清单 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| title.inner_html())
}
代码清单 17-2:使用 await 关键字进行链式调用

至此,我们已经成功编写了第一个异步函数!在我们向 main 中添加一些 代码来调用它之前,让我们再多谈谈我们写了什么以及这意味着什么。

当 Rust 看到一个用 async 关键字标记的块时,它会将其编译为 一个实现了 Future trait 的唯一匿名数据类型。当 Rust 看到一个用 async 标记的函数时,它会将其编译为一个非异步函数, 其函数体是一个 async 块。异步函数的返回类型就是编译器为该 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 语法。
  • 返回的值实现了 Future trait,其关联类型为 Output。请注意, Output 类型是 Option<String>,与 page_title 的 async fn 版本 的原始返回类型相同。
  • 原始函数体中调用的所有代码都被包裹在一个 async move 块中。 请记住,块是表达式。这整个块就是从函数返回的表达式。
  • 这个 async 块产生一个类型为 Option<String> 的值,正如刚才所述。 该值与返回类型中的 Output 类型相匹配。这与你见过的其他块类似。
  • 新的函数体是一个 async move 块,这是因为它使用了 url 参数的方式。 (我们将在本章后面更详细地讨论 async 与 async move 的区别。)

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

使用运行时执行异步函数

首先,我们将获取单个页面的标题,如代码清单 17-3 所示。 遗憾的是,这段代码还不能编译。

文件名: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| title.inner_html())
}
代码清单 17-3:在 main 中使用用户提供的参数调用 page_title 函数

我们沿用了第 12 章「接收命令行参数」一节中 获取命令行参数的相同模式。然后我们将 URL 参数传递给 page_title 并等待结果。 因为 future 产生的值是 Option<String>,所以我们使用 match 表达式 根据页面是否有 <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 的原因是异步代码需要一个运行时(runtime): 一个管理异步代码执行细节的 Rust crate。程序的 main 函数可以初始化 一个运行时,但它本身不是一个运行时。(稍后我们会看到更多关于为何如此 的解释。)每个执行异步代码的 Rust 程序都至少有一个设置运行时并执行 futures 的地方。

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

在本章的其余部分,我们将使用 trpl crate 中的 block_on 函数, 它接受一个 future 作为参数,并阻塞当前线程直到该 future 运行完毕。 在幕后,调用 block_on 会使用 tokio crate 设置一个运行时来运行传入的 future(trpl crate 的 block_on 行为类似于其他运行时 crate 的 block_on 函数)。一旦 future 完成,block_on 就返回该 future 产生的值。

我们可以直接将 page_title 返回的 future 传递给 block_on, 一旦它完成,我们就可以像代码清单 17-3 中尝试的那样对结果 Option<String> 进行匹配。然而,对于本章中的大多数示例(以及 现实世界中的大多数异步代码),我们将要做的不仅仅是一个异步函数调用, 所以我们将传入一个 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::block_on(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| title.inner_html())
}
代码清单 17-4:使用 trpl::block_on 等待一个异步块

当我们运行这段代码时,我们得到了最初期望的行为:

$ 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 点(await point)——也就是代码中使用 await 关键字的 每个位置——都代表一个将控制权交还给运行时的位置。为了使其工作, Rust 需要跟踪 async 块中涉及的状态,这样运行时就可以启动其他工作, 然后在准备好时回来继续推进第一个任务。这是一个不可见的状态机, 就好像你写了一个这样的枚举来在每个 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 编译器会自动创建和管理异步代码的状态机数据结构。关于数据结构的 常规借用和所有权规则仍然适用,而且令人高兴的是,编译器也会为我们处理 这些检查并提供有用的错误信息。我们将在本章后面处理其中一些错误信息。

最终,必须有什么东西来执行这个状态机,而这个东西就是运行时。 (这就是为什么在研究运行时时,你可能会遇到执行器(executor)的提法: 执行器是运行时中负责执行异步代码的部分。)

现在你可以理解为什么在代码清单 17-3 中编译器阻止我们将 main 本身 标记为异步函数了。如果 main 是异步函数,就需要其他东西来管理 main 返回的任何 future 的状态机,但 main 是程序的起点! 相反,我们在 main 中调用了 trpl::block_on 函数来设置运行时, 并运行 async 块返回的 future 直到它完成。

注意:一些运行时提供了宏,让你可以编写异步的 main 函数。 这些宏会将 async fn main() { ... } 重写为普通的 fn main, 其作用与我们在代码清单 17-4 中手动所做的相同:调用一个函数, 像 trpl::block_on 那样运行一个 future 直到完成。

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

并发地让两个 URL 相互竞争

在代码清单 17-5 中,我们使用从命令行传入的两个不同 URL 调用 page_title,并通过选择先完成的 future 来让它们相互竞争。

文件名: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::block_on(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::select(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 was: '{title}'"),
            None => println!("It had no title."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let response_text = trpl::get(url).await.text().await;
    let title = Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}
代码清单 17-5:对两个 URL 调用 page_title 以查看哪个先返回

我们首先对用户提供的一个 URL 分别调用 page_title。我们将生成的 futures 保存为 title_fut_1 和 title_fut_2。请记住,这些 futures 还没有 执行任何操作,因为 futures 是惰性的,我们还没有等待它们。然后我们将 futures 传递给 trpl::select,它会返回一个值来指示传递给它的哪个 future 先完成。

注意:在底层,trpl::select 构建于 futures crate 中定义的一个 更通用的 select 函数之上。futures crate 的 select 函数可以做很多 trpl::select 函数做不到的事情,但它也有一些额外的复杂性, 我们暂时可以跳过。

任何一个 future 都可能合理地「获胜」,所以返回 Result 没有意义。 相反,trpl::select 返回一个我们以前没见过的类型 trpl::Either。 Either 类型与 Result 有些相似,因为它有两种情况。但与 Result 不同, Either 中并没有内置成功或失败的概念。相反,它使用 Left 和 Right 来表示「两者之一」:

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

如果第一个参数获胜,select 函数返回 Left,其中包含该 future 的输出; 如果第二个参数获胜,则返回 Right,包含第二个 future 参数的输出。 这与调用函数时参数出现的顺序相匹配:第一个参数在第二个参数的左边。

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

你现在已经构建了一个小型可工作的网页爬虫!挑选几个 URL 并运行命令行 工具。你可能会发现有些网站一直比其他网站快,而在其他情况下,更快的 网站会因运行而异。更重要的是,你已经学习了 futures 的基础知识, 所以现在我们能够更深入地探讨异步编程能做什么了。