- 教程
- 文档
【免费下载链接】book
The Rust Programming Language
本篇技术指南围绕《The Rust Programming Language》(即本仓库 book 项目)第 17 章"异步编程"中关于 Future 与 async/await 语法的核心章节展开,讲解 Rust 中 future 的惰性执行模型、async fn的编译器转换原理、运行时(runtime)与block_on的配合方式,并带领读者基于trpl教学辅助 crate 从零构建一个并发抓取网页标题的命令行工具。读完本篇,你将掌握 Rust 异步代码的最小可运行骨架:定义异步函数、用运行时驱动 future、以及用select并发竞速多个 future。
异步编程的两大基石:future 与 async/await
Rust 异步编程的关键要素是future以及async与await两个关键字。
所谓future,是指"现在可能尚未就绪,但在未来某个时刻会就绪"的值。这个概念在很多语言中都以不同名字出现,比如task或promise。Rust 通过标准库提供Futuretrait 作为统一构建块,使得不同的异步操作可以用不同的数据结构实现,却共享同一套接口。在 Rust 中,future 就是实现了Futuretrait 的类型,每个 future 自己保存着"进展到了哪一步、'就绪'意味着什么"的信息。
async关键字可以应用于代码块和函数,表示它们可以被中断(pause)和恢复(resume)。在 async 块或 async 函数内部,可以使用await关键字来await 一个 future——也就是等待它变为就绪。async 块或函数中任何await一个 future 的位置,都是该块或函数潜在的暂停与恢复点。反复询问 future 的值是否可用的过程被称为polling(轮询)。
C# 和 JavaScript 等语言同样使用async/await关键字做异步编程,但 Rust 在语法细节上存在显著差异(后面会看到await是后置关键字这一点)。Rust 编译器会把async/await编译成等价的使用Futuretrait 的代码,正如它把for循环编译成使用Iteratortrait 的等价代码一样。同时,因为 Rust 提供了Futuretrait,你也能在需要时为自定义数据类型手动实现它。本章后续许多函数返回的都是带有各自Future实现(或借用trplcrate 等第三方实现)的类型。
第一个异步程序:抓取网页标题的命令行工具
理论稍显抽象,我们直接写第一个异步程序:一个小型网页抓取器。它从命令行接收两个 URL,并发抓取这两个页面,返回"先完成整个流程"的那一个的结果。这个示例会包含不少新语法,但我们会逐步解释。
trpl 教学辅助 crate
为了让本章专注学习 async 本身、而不是纠缠生态中的各种细节,本书创建了trplcrate(trpl是 "The Rust Programming Language" 的缩写)。它重新导出了所需的所有类型、trait 和函数,主要来自futures与tokio两个 crate:futures是 Rust 官方用于异步代码实验的 crate,Futuretrait 最初正是在这里设计的;Tokio 则是当前 Rust 中最广泛使用的异步运行时,尤其适合 Web 应用。本书在trpl内部使用 Tokio,因为它经过了充分测试且广泛使用。
在本仓库中,trpl的完整实现位于 packages/trpl/src/lib.rs,其依赖声明可见 packages/trpl/Cargo.toml:它直接依赖futures 0.3、reqwest 0.12(启用rustls-tls)、scraper 0.20、tokio 1与tokio-stream 0.1。从源码结构看,crate 存在的主要意义是:读者只需添加一个依赖、使用一套导入,并且由于本书完全控制该 crate 的内容和变更时机,读者不会因为上游破坏性更新(例如 Tokio 潜在的 2.0)而被破坏。此外,trpl还会重命名或包装原始 API,并留下大量注释说明每个重新导出来自哪个 crate。
创建新二进制项目并添加依赖:
$ cargo new hello-async $ cd hello-async $ cargo add trpl仓库中对应示例项目的清单文件 listings/ch17-async-await/listing-17-01/Cargo.toml 展示了实际依赖写法(edition = "2024",trpl以 path 依赖引入)。
定义 page_title 异步函数
先写一个接收页面 URL 参数、发起请求并返回<title>元素文本的函数(见 Listing 17-1,完整代码在 listings/ch17-async-await/listing-17-01/src/main.rs):
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()) }分解来看:
- 函数名为
page_title,用async关键字标记; trpl::get(url)抓取传入的 URL,并用await等待响应。从 packages/trpl/src/lib.rs 的实现可见,trpl::get是对reqwest::get的包装,请求失败时直接 panic(而非返回Result),以简化教学示例;- 拿到
response后调用其text方法并再次await。这一步也是异步的:get只需等待服务器发回响应的第一部分(HTTP 头、cookie 等),而text必须等待完整响应体到达,尤其当响应体很大时耗时更长。在trpl源码中,Response::text是对reqwest::Response::text()的薄封装(packages/trpl/src/lib.rs); - 得到
response_text后,用Html::parse把它解析成Html类型实例——这不再是裸字符串,而是可用于结构化操作的丰富数据类型。trpl::Html在 packages/trpl/src/lib.rs 中是对scraper::Html的薄封装,Html::parse实际调用scraper::Html::parse_document; - 用
select_first("title")查找文档中第一个匹配给定 CSS 选择器的元素。由于页面可能没有匹配元素,它返回Option<ElementRef>(内部通过scraper::Selector::parse解析选择器并取第 0 个匹配项,见 packages/trpl/src/lib.rs); - 最后用
Option::map:若Option中有值则处理,否则不做任何事(也可用match,但map更惯用)。在传给map的函数体中调用inner_html取得title的内容,类型为String。最终得到Option<String>。
为什么必须显式 await:future 是惰性的
我们必须显式 await 上面这两个 future,因为 Rust 的 future 是lazy(惰性)的:在你用await关键字请求它们之前,它们不会做任何事情(事实上,如果你创建了一个 future 却不用它,Rust 会给出编译器警告)。这一点与第 13 章"使用迭代器处理元素序列"中讨论的迭代器很像:迭代器在你调用next方法之前什么都不做——无论是直接调用,还是通过for循环或map这类在底层使用next的方法。同样,future 在你显式请求之前也不会执行。这种惰性让 Rust 可以避免在真正需要之前运行异步代码。
注意:这与第 16 章"使用 spawn 创建新线程"中
thread::spawn的行为不同——那里传给新线程的闭包会立刻开始运行。它也与许多其他语言的异步处理方式不同。但这种惰性对 Rust 提供其性能保证至关重要,正如迭代器的情况一样。
await 是后置关键字:链式调用更优雅
注意 Rust 的await关键字放在被等待的表达式之后,而不是之前——它是postfix(后置)关键字。这与你可能熟悉的其他语言的async用法不同,但在 Rust 中它让方法链更易用。因此可以把page_title的函数体改写为把trpl::get和text调用串联起来、中间用await分隔(见 Listing 17-2,完整代码在 listings/ch17-async-await/listing-17-02/src/main.rs):
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()) }到这里,我们写出了第一个异步函数。
async fn 的编译器转换:返回一个 Future
当 Rust 看到一个以async关键字标记的代码块时,会把它编译成一个唯一的、匿名的、实现Futuretrait 的数据类型。当 Rust 看到一个以async标记的函数时,会把它编译成一个非 async 函数,其函数体是一个 async 块。async 函数的返回类型,正是编译器为该 async 块创建的匿名数据类型的类型。
因此,写async fn等价于写一个返回"返回类型之 future"的函数。对编译器而言,Listing 17-1 中async fn page_title的定义大致等价于下面这个非 async 函数:
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语法; - 返回值实现
Futuretrait,其关联类型为Output。注意这里的Output类型是Option<String>,与原始async fn版本的page_title返回类型一致; - 原函数体中调用的所有代码都被包在一个
async move块中。回想一下,块是表达式,整个块就是从函数返回的表达式; - 该 async 块产生一个类型为
Option<String>的值,与返回类型中的Output匹配——这和你见过的其他块完全一样; - 新函数体使用
async move块,是因为它对url参数的使用方式(本章后续会详细讨论async与async move的区别)。
用运行时执行异步函数
为什么 main 不能是 async:需要一个运行时
接下来在main中调用page_title,获取单页面的标题(见 Listing 17-3,代码在 listings/ch17-async-await/listing-17-03/src/main.rs)。这里沿用第 12 章"接收命令行参数"中的参数获取模式,然后把 URL 传给page_title并await结果。由于 future 产生的值是Option<String>,我们用match表达式根据页面是否包含<title>打印不同消息。
可惜这段代码目前无法编译——因为await关键字只能在 async 函数或 async 块中使用,而 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 程序,至少有一个位置用来设置执行 future 的运行时。
大多数支持 async 的语言会把运行时捆绑在内,但 Rust 没有。相反,存在许多不同的异步运行时,各自针对目标场景做出不同的取舍。例如:一个拥有多 CPU 核心、大内存、追求高吞吐的 Web 服务器,与一个单核心、小内存、没有堆分配能力的微控制器,需求截然不同。提供这些运行时的 crate 通常也提供文件或网络 I/O 等常见功能的异步版本。
用 trpl::block_on 搭建第一个可运行程序
本章(以及大多数真实世界 async 代码)会使用trplcrate 的block_on函数:它接收一个 future 作为参数,阻塞当前线程直到该 future 运行完成。在 packages/trpl/src/lib.rs 中可以看到实现:每次调用都会新建一个 TokioRuntime并调用rt.block_on(future)。trpl的block_on行为与其他运行时 crate 的block_on类似;文档注释还指出,它和 Tokio 自己的tokio::main宏在底层支持async fn main的做法相差不远。trpl还保留了旧名称run作为block_on的兼容别名(packages/trpl/src/lib.rs),集成测试 packages/trpl/tests/integration/main.rs 对二者均有验证。
理论上可以把page_title返回的 future 直接传给block_on,完成后对得到的Option<String>做 match。但为了贴近真实场景,我们传入一个async块并在其中显式 awaitpage_title调用的结果,如 Listing 17-4 所示(完整代码在 listings/ch17-async-await/listing-17-04/src/main.rs):
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"), } }) }运行它,得到预期的行为:
$ 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 点与隐式状态机
在添加两个站点竞速的代码之前,先回头看看 future 究竟如何工作。
每个await 点——即代码中使用await关键字的每一处——都代表控制权交还给运行时的地方。为此,Rust 需要跟踪 async 块涉及的状态,以便运行时可以先去做别的工作,等第一个 async 块可推进时再回来。这就像一个隐式的状态机,仿佛你手写了一个类似下面的枚举,在每个 await 点保存当前状态(对应代码见 listings/ch17-async-await/no-listing-state-machine/src/lib.rs):
enum PageTitleFuture<'a> { Initial { url: &'a str }, GetAwaitPoint { url: &'a str }, TextAwaitPoint { response: trpl::Response }, }手动编写状态之间的转换代码会非常繁琐且易错,尤其当后续需要增加更多功能和状态时。好在 Rust 编译器会自动创建并管理异步代码的状态机数据结构。数据结构上正常的借用与所有权规则仍然适用,编译器也会帮我们检查这些规则并给出有用的错误消息。
最终,必须有东西来执行这个状态机——那个"东西"就是运行时。(这也是为什么你在研究运行时时会遇到executors一词:executor 是运行时中负责执行异步代码的部分。)
现在你能明白为什么编译器在 Listing 17-3 中阻止我们把main本身写成 async 函数了:如果main是 async 函数,就需要有别的什么东西来管理main返回的 future 的状态机,但main是程序的起点!于是我们改为在main中调用trpl::block_on,设置一个运行时,并运行async块返回的 future 直到完成。
注意:有些运行时提供宏,让你可以编写 async
main函数。这些宏会把async fn main() { ... }重写为普通的fn main,做的事情与我们手工在 Listing 17-4 中做的一样:调用一个函数,以类似trpl::block_on的方式把 future 运行到完成。
并发竞速两个 URL
下面把各部分拼起来,写出并发代码(见 Listing 17-5,完整代码在 listings/ch17-async-await/listing-17-05/src/main.rs):
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) }我们对每个用户提供的 URL 调用page_title,把得到的 future 保存为title_fut_1和title_fut_2。记住:这些 future 现在还没做任何事,因为 future 是惰性的,我们还没 await 它们。然后我们把这两个 future 传给trpl::select,它返回一个值,指示传给它的两个 future 哪个先完成。
注意:底层上,
trpl::select构建在futurescrate 中更通用的select函数之上。futurescrate 的select能做很多trpl::select做不到的事,但它也有一些目前可以略过的额外复杂性。
两个 future 都可能合理地"获胜",所以返回Result没有意义。trpl::select返回的是一个此前没见过的新类型trpl::Either。Either与Result相似,也有两个分支,但与Result不同的是,Either没有内建"成功/失败"的概念,而是用Left和Right表示"二选一":
enum Either<A, B> { Left(A), Right(B), }如果第一个参数获胜,select返回Left(携带该 future 的输出);如果第二个参数获胜,则返回Right(携带第二个 future 参数的输出)。这正与调用函数时参数的顺序对应:第一个参数在第二个参数的左边。
从trpl源码看,select的实现(packages/trpl/src/lib.rs)会先pin!两个 future,然后await futures::future::select,并丢弃较慢的那个 future(这与futurescrate 中select不丢弃较慢 future 的语义不同——后者允许你先处理第一个结果、之后还可以继续等待第二个 future)。trpl的select之所以能这么"简单",是因为它获得了 future 的所有权、内部固定(pin)了它们、并丢弃未使用的 future。旧名称race作为select的兼容别名保留(packages/trpl/src/lib.rs)。集成测试 packages/trpl/tests/integration/main.rs 中分别用"慢 future 睡 1000 毫秒、快 future 睡 1 毫秒"的用例验证了select与race都会返回Either::Right(Fast)。
我们还更新了page_title,让它把传入的 URL 一并返回。这样,即使先返回的页面没有可解析的<title>,我们仍能打印有意义的消息。最后更新println!输出,标明哪个 URL 先完成、以及该 URL 页面的<title>是什么(如果有)。
至此,你已经构建了一个小型可用的网页抓取器!挑几个 URL 运行这个命令行工具,你可能会发现有些站点稳定地更快,另一些情况下快慢因运行而异。更重要的是,你已经掌握了使用 future 的基础知识,可以继续深入探索 async 能做什么了。
结合仓库源码的验证与扩展路径
想要亲手验证上述行为,本仓库提供了完整的可运行示例与测试:
- 各阶段示例代码:从 listing-17-01 到 listing-17-05,对应文中的五个代码清单;
- 隐式状态机示意枚举:no-listing-state-machine/src/lib.rs;
trplcrate 完整实现:packages/trpl/src/lib.rs,其中block_on、get、Response、Html、select的源码注释解释了每个 API 的设计动机与底层来源;trpl依赖与特性声明:packages/trpl/Cargo.toml;- 集成测试:packages/trpl/tests/integration/main.rs,覆盖
block_on、select/race、join系列、channel、stream、Html::parse等全部教学 API; - 本章开篇(src/ch17-00-async-await.md)阐述了并行与并发的区别、CPU 密集与 I/O 密集操作的分类,可作为理解 future 适用场景的背景;后续章节 src/ch17-02-concurrency-with-async.md 及以后会继续深入
async与async move、消息传递、join、stream 等内容。
从trpl的依赖配置可以看出,它的功能建立在tokio(fs、rt-multi-thread、sync、time特性)、reqwest(rustls-tls特性)、scraper与tokio-stream之上——这也印证了正文的观点:Rust 官方不捆绑运行时,而是由生态中的 crate 提供,trpl在此处仅起教学封装作用。理解这一点,你在面对真实项目时就能基于同样的Futuretrait 机制,按需选择或实现自己的运行时与异步 I/O。
- 教程
- 文档
【免费下载链接】book
The Rust Programming Language
相关推荐
100-exercises-to-learn-rust 异步编程实战指南:async/.await、Future 与 tokio 运行时
100 exercises to learn rust 异步编程实战指南:async/.await、Future 与 tokio 运行时 Rust 的并发编程并
示例工程教程Rust异步编程实现:async/await背后的Future机制
Rust异步编程实现:async/await背后的Future机制 你是否曾好奇,当写下 async fn 时,Rust编译器究竟在背后做了什么?为什么看似同步
编程语言编译器语言运行时标准库comprehensive-rust 课程解读:Rust Async 异步编程基础——Futures、async/await、任务与运行时
comprehensive rust 课程解读:Rust Async 异步编程基础——Futures、async/await、任务与运行时 本文基于 Googl
文档教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考