在「Rust学习笔记 - Future、Waker 与异步任务调度」中提到过,Future 可以看作一个状态机。它会保存当前执行到了哪里、后续需要哪些局部变量,以及正在等待什么。
前言
async/await 可以让异步代码看起来像普通的顺序代码:
1 | use tokio::time::{sleep, Duration}; |
这段代码先输出 start,等待 1 秒后再输出 finished。等待期间,run() 对应的 Future 会返回 Poll::Pending,执行到这里暂停。
定时器到期后,运行时会再次调用这个 Future 的 poll。这次不会重新输出 start,而是从 sleep(...).await 后面继续执行。
那么,Future 是怎么记住上次执行到哪里呢?
async fn 中有哪些状态
Rust 编译器会将 async fn 转换成一个实现了 Future 的状态机。以前面的 run() 为例,为了方便理解,可以把它的执行过程简单分成三个状态:
Start:还没有开始执行。Sleeping:已经输出start,正在等待定时器到期。Done:已经输出finished,执行完成。
用伪代码表示,大致如下:
1 | enum RunState { |
-
刚调用
run()时,函数体还没有执行,对应的 Future 处于Start状态。 -
第一次调用
poll,代码输出start,接着遇到sleep(...).await。此时定时器还没有到期,代码无法继续往下执行。Future 会把这个Sleep保存下来,进入Sleeping状态,然后返回Poll::Pending。 -
1 秒后,定时器通过
Waker唤醒任务。运行时再次调用poll,这次不需要从头执行,而是继续轮询之前保存的Sleep。等它返回Poll::Ready(()),代码继续输出finished,整个 Future 进入Done状态。
这里不能只记住“正在等待”,还要把之前创建的 Sleep 一起保存下来。否则下次调用 poll 时,只能重新创建一个定时器,计时也会重新开始。
这个例子在 .await 等待期间只需要保存一个 Sleep。如果还有局部变量在 .await 之后继续使用,它们同样需要成为状态机的一部分。
跨越 await 的局部变量
状态机保存的不只有正在等待的 Future。看下面这个稍加改造的例子:
1 | async fn run() { |
这里的 message 在 .await 之前创建,却要在 .await 之后继续使用。因此,message 也需要和 Sleep 一起保存在状态机中。前面的枚举可以进一步修改为:
1 | enum RunState { |
这里注意并不是 async fn 中出现的所有局部变量都会被保存。只有在暂停之后还需要继续存活的变量,才需要成为状态机的一部分。
await 如何轮询内部 Future
run() 对应的是外层 Future,Sleep 则是它正在等待的内部 Future。当状态机处于 Sleeping 状态时,外层 Future 会继续轮询之前保存的 Sleep。
sleep(...).await 的执行过程可以简化为:
1 | match sleep.as_mut().poll(cx) { |
外层 Future 在轮询 Sleep 时,会把自己的 Context 继续传递下去。Sleep 可以通过其中的 Waker 记录当前任务,等定时器到期后,再通知运行时重新调度这个任务。
如果 Sleep 返回 Poll::Pending,说明定时器还没有到期,外层 Future 也会返回 Poll::Pending。状态机仍然停留在 Sleeping,其中保存的 Sleep 和 message 都不会被丢弃。
等外层 Future 再次被调用 poll 时,它会继续轮询同一个 Sleep。当 Sleep 返回 Poll::Ready(()),.await 完成,代码继续输出 message,状态机进入 Done。
这个轮询过程有些像普通的函数调用栈:
-
相似的是,运行时先调用最外层 Future 的
poll,外层 Future 再继续调用内部 Future 的poll。内部 Future 的poll返回后,控制权会回到外层 Future。如果返回Poll::Ready,外层 Future 取得结果并继续执行,如果返回Poll::Pending,外层 Future 通常也会将Poll::Pending继续向外返回。 -
不同的是,返回
Poll::Pending后,这次poll形成的调用栈就已经退出,不会一直保留在线程栈中。恢复执行所需的位置和局部变量都保存在 Future 自身的状态机里。等任务被再次唤醒,运行时会重新从最外层 Future 开始调用poll。
实现 run 的状态机
前面的 RunState 只是一个简化的状态模型。要让它真正作为 Future 运行,还需要定义一个 RunFuture,用来保存当前状态:
1 | struct RunFuture { |
RunFuture::new() 只创建状态机,并不会执行原来 run() 中的代码。和调用 async fn 一样,真正的执行要等到第一次调用 poll 才会开始。接下来为 RunFuture 实现 Future:
1 | impl Future for RunFuture { |
在 Start 状态中,第一次调用 poll 会输出 start,创建 Sleep 和 message,然后进入 Sleeping。在 Sleeping 状态中,状态机会轮询之前保存的 Sleep:
- 如果返回
Poll::Pending,保留当前状态并等待下一次轮询。 - 如果返回
Poll::Ready(()),输出message,进入Done,最后返回Poll::Ready(())。
Future 返回 Poll::Ready(()) 后就已经执行完成,调用方不应该再次轮询它。这里选择在 Done 状态中直接 panic!,用于暴露这种错误调用。
注意,代码使用 loop,是为了切换状态后,在同一次 poll 中继续轮询刚创建的 Sleep。如果还没有调用过 Sleep::poll 就直接返回 Poll::Pending,Sleep 也没有机会通过 Context 注册当前任务的 Waker。
可以使用 Tokio 运行时驱动这个手写的 Future:
1 |
|
多个 await 会咋
每个 .await 都是一个可能暂停的位置。如果一个异步函数中有多个 .await,状态机还需要记录当前停在哪一个位置。例如:
1 | async fn run() { |
这个 async fn 有两个可能暂停的位置,前面的枚举可以修改成这样:
1 | enum RunState { |
如果第一个 Sleep 返回 Poll::Pending,状态机需要保存它,并记录当前停在第一个 .await。等它完成后,代码继续输出 middle,然后开始轮询第二个 Sleep。
如果第二个 Sleep 也返回 Poll::Pending,状态机再次保存当前执行位置和正在等待的 Sleep。等它完成后,代码输出 finished,状态机进入 Done。
从执行顺序上看,这是一条不断向前推进的状态转换路径:
最后
Future 不是通过保留线程的调用栈来记住执行位置。async fn 返回的 Future 本身就是一个状态机,其中保存了当前执行到哪里、正在等待的内部 Future,以及跨越暂停点仍然需要使用的局部变量。
运行时每次调用 poll,状态机都会从当前状态继续执行。遇到 .await 时,外层 Future 会轮询内部 Future。如果内部 Future 返回 Poll::Pending,外层 Future 也会保存当前状态并返回 Poll::Pending。等任务被再次唤醒,运行时再从最外层 Future 开始调用 poll。
async/await 写起来像普通的顺序代码,实际执行时却是在一次次 poll 中不断推进状态机。
