Appearance
25.5 异步编程
async 和 await 关键字是目前许多语言都采用的主流方案,使用关键字而不是用宏来做 API,有助于社区的统一性,避免不同的异步方案使用完全不一样的用户 API。
在 Rust 语言和标准库中,只有极少数必须的关键字、trait 和类型,这也是 Rust 一贯的设计思路。但凡是可以在第三方库中实现的,一律在第三方库中实现,哪怕这个库本来就是官方核心组维护的,这样做可以让这个库的版本升级更灵活,有助于标准库的稳定性。
从上一章节我们知道,Rust 的异步编程模型基于 Future 机制(底层实现是通过 Coroutine 状态机),而 async/await 语法糖让异步代码看起来像是同步的。
25.5.1 核心构件 Future
Future 是一个 Trait,Future 可以组合,一个 Future 可以由其他的一个或者多个 Future 包装而成。跟我们已经见过的迭代器 Iterator 很像。比如,我们可以实现一个新的 Future,它的结果是多个 Future 按顺序执行得到的。或者,实现一个 Future,它的结果是两个子 Future 中先返回的那个。Future 组合的方式可以非常灵活。
其简化定义如下:
rust
// rust-lib/core/src/future/future.rs
use crate::ops;
use crate::pin::Pin;
use crate::task::{Context, Poll};
pub trait Future {
// 这就是“凭证”未来能兑换的“商品”类型
type Output;
// “轮询”一下,看看“商品”到货了没
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
这里有两个关键点:
Output
类型:定义了这个Future
完成后会返回什么类型的值。比如,一个网络请求的Future
,其Output
可能是一个String
或Vec<u8>
。poll
方法:这是Future
的心脏。它的作用就是去“问一下”:“我的任务完成了吗?”poll
方法的返回值Poll<T>
是一个枚举:rustpub enum Poll<T> { Ready(T), Pending, }
想象一下,你不断地去问餐厅前台:“我的外卖好了吗?”
你 -> poll() -> 前台 | +--> "还没好呢!" (返回 Poll::Pending) | +--> "好了,给你!" (返回 Poll::Ready(你的外卖))
但是我们不断使用 poll
来查询状态,效率太低了。你告诉前台:“我的外卖好了,请打电话通知我。” 这个“电话号码”就是 Waker
。
poll
方法的参数 cx: &mut Context<'_>
就包含了这个 Waker
。
- 当一个
Future
的poll
方法被调用,发现任务还没准备好(例如,网络数据还没到),它会返回Poll::Pending
。 - 在返回之前,它会“克隆”一份
Waker
并存起来。 - 当底层事件完成时(例如,操作系统通知网络数据已到达),它会调用
Waker
的wake()
方法。 - 这个
wake()
调用就像是给执行器(Executor,下一节讲)打了个电话:“嘿,那个任务现在有进展了,快再来poll
它一次吧!”
我们有了可以被暂停和唤醒的任务(Future
),但谁来管理和驱动它们呢?答案是 Executor
(调度器/执行器),也常被称为 Runtime
(运行时)。它的具体实现可以由第三方库来实现。Executor
就像是厨房中统筹全局的厨师长。他手里有一份“任务清单”(一堆 Future
s),他会不断地轮询这些任务,推动它们向前执行。
一个 Executor
的基本工作流程:
- 接收任务:你将一个顶层的
Future
交给Executor
。 - 事件循环:
Executor
启动一个循环,从任务队列中取出一个任务。 - 驱动任务:调用任务的
poll
方法。 - 处理结果:
Poll::Ready(value)
:任务完成!Executor
可以将结果交给用户,或者处理下一个任务。Poll::Pending
:任务被暂停。Executor
会把它放到一边,这样就不会占用 CPU,等待着被Waker
唤醒。当Waker
被调用时,Executor
会把这个任务重新放回待执行的队列里。
- 重复:
Executor
不断重复这个过程,确保所有任务都能在适当的时候得到执行。
Rust 标准库只提供了 Future
trait 等核心构建块,但 没有内置 Executor
。这是 Rust 的设计哲学:保持标准库的精简和稳定。
你需要选择一个第三方的
Runtime
库来实际运行你的异步代码。最流行的选择是:
tokio
: 社区生态最庞大,功能全面,为生产环境设计。async-std
: API 设计上力求与标准库同步std
模块相似,学习曲线平缓。
大家可以看到,Future 跟 Coroutine 一样,具备同样的特性,也就是说可以在某个地方主动中断执行,待下一次再进来的时候,刚好可以从上次退出的地方恢复执行。这就是为什么 Rust 的 Future 最终是基于 Coroutine 实现的。 在 Rust 里面,Coroutine 是 Future 的基础设施。
一般情况下,实现任务调度以及为通过各种异步操作实现 Future trait 并不是最终用户关注的问题,这些应该都已经被网络开发框架完成,比如 tokio。大部分用户需要关注的是如何利用这些框架完成业务逻辑。
25.5.2 语法糖 async/await
手动实现 Future
和管理 poll
逻辑非常繁琐。所以有了 async/await 语法,可以让编译器为我们处理所有复杂的工作。
调用一个 async
函数 仅仅是创建了一个 Future
实例,它什么都还没做!就像你拿到了“取餐凭证”,但饭还没开始做。你必须把这个返回的 Future
交给一个 Executor
来运行,它才会真正被执行。
早期的 await!
在 Rust 的异步编程早期(2018-2019 年左右),Rust 还没有正式支持 async/await 语法。当时,异步编程主要通过 futures 库实现。为了方便开发者在异步代码中等待某个 Future 的结果,futures 库提供了一个宏 await!
。这个宏的作用是将异步的 Future 转换为阻塞式的结果,类似于后来正式引入的 .await
关键字。
早期,你可能会看到这样的代码:
rust
use futures::Future;
fn some_async_function() -> impl Future<Output = i32> {
async { 42 }
}
fn main() {
let result = futures::executor::block_on(async {
let value = await!(some_async_function());
value
});
println!("{}", result);
}
对于 await 这个宏,我们可以在标准库中看到它的实现:
rust
macro_rules! await {
($e:expr) => { {
let mut pinned = $e;
let mut pinned = unsafe { $crate::mem::PinMut::new_unchecked(&mut pinned) };
loop {
match $crate::future::poll_in_task_cx(&mut pinned) {
$crate::task::Poll::Pending => yield,
$crate::task::Poll::Ready(x) => break x,
}
}
} }
}
如果你在非常老旧的代码库中工作,可能仍然会遇到 await!
宏。这种情况下,你需要确保项目依赖了旧版的 futures 库(例如 futures 0.1)。但我强烈建议将代码升级到现代 Rust,使用 .await
语法,因为 await!
已经不再维护,且旧版库可能存在兼容性问题。
现代的 async/await
自 Rust 1.39.0 开始,Rust 正式引入了 async/await 语法,await!
宏被废弃,取而代之的是 .await
关键字。从语法上讲,.await
只能出现在 async
函数、async
闭包或 async
代码块里,不然编译器直接报错。
编译器会将一个 async
函数转换成一个状态机(State Machine)。
rust
async fn fetch_and_process() {
// --- 状态 0 ---
let url = "https://www.rust-lang.org";
let response = fetch(url).await; // 暂停点 1
// --- 状态 1 ---
let text = response.text().await; // 暂停点 2
// --- 状态 2 ---
println!("网页内容: {}", text);
// --- 最终状态 ---
}
这个状态机实现了 Future
trait。每次 Executor
来 poll
它:
- 如果处于状态 0,它会执行
fetch(url)
,然后poll
返回的Future
。 - 如果返回
Poll::Pending
,那么整个fetch_and_process
函数就会暂停,整个状态机就 暂停(yield) 在状态 0,并把控制权交还给Executor
,让它可以去执行其他任务。 - 当
fetch(url)
的Future
被Waker
唤醒(resume) 并最终返回Poll::Ready(value)
时,Executor
会回到fetch_and_process
函数暂停的地方,把value
赋值给response
,然后继续向下,会进入状态 1。
- 如果处于状态 1,它会执行
response.text()
,然后poll
返回的Future
,逻辑同上。 - ...以此类推,直到最终状态。
可以看到,使用 async/await 来写异步逻辑,一方面可以保证高效率,另一方面,代码流程还是跟普通的同步逻辑类似,比较符合直觉。
每次碰到 .await
,就是告诉调度器:“我等一会儿,别让我的代码占用 CPU,其他任务可以先执行;东西准备好了再叫我回来。”。这一块被编译器包装进生成的状态机,巧妙地隐藏了所有 poll
, Pending
, Ready
和 Waker
的细节,自动帮你管理状态、挂起、唤醒流程。
下面给大家解释一下 async 和 await 分别做了什么事情。
async 关键字可以修饰函数、闭包以及代码块。对于函数:
rust
async fn read_from_db(id: u32) -> String {
// ... 模拟数据库查询 ...
"some data".to_string()
}
在编译器看来,等价于这个返回 impl Future
的普通函数::
rust
fn read_from_db_desugared(id: u32) -> impl Future<Output = String> {
// 编译器在这里会为你生成一个实现了 Future 的匿名状态机
// ...
}
这两种写法实际上是一模一样的。凡是被 async
修饰的函数,返回的都是一个实现了 Future trait 的类型。 由 async 修饰的闭包也是一样的。async 代码块同样类似。它相当于创建了一个语句块表达式,这个表达式的返回类型是 impl Future
。
async 关键字不仅对函数签名做了一个改变,而且对函数体也自动做了一个包装,被 async
关键字包起来的部分,会自动产生一个 Coroutine,并把这个 Coroutine 包装成一个满足 Future 约束的结构体。在函数体中用户需要返回的是 Future::Output
类型,也就是异步任务成功后得到的结果,比如 String
、io::Result<T>
等等。
25.5.3 总结
让我们回顾一下 Rust 异步编程的全景图:
- 为什么异步? 为了高效处理大量并发 I/O 任务,避免线程阻塞,就像一个聪明的厨师。
Future
是什么? 是一个异步任务的“凭证”,代表一个未来会完成的值。它可以通过poll
方法查询状态(Ready
或Pending
)。Executor
是什么? 是任务的“调度器”或“运行时”(如tokio
),负责驱动Future
s 执行,并在它们Pending
时暂停,在被Waker
唤醒后继续。async/await
是什么? 是 Rust 提供的“语法糖”,让我们能用近乎同步的方式编写异步代码。async
将函数变为Future
生成器,.await
则优雅地处理了任务的暂停和恢复。
通过这套精心设计的系统,Rust 成功地在提供极致性能的同时,保持了其赖以成名的内存安全保证,让编写大规模、高并发的程序变得前所未有的简单和安全。