Rust异步状态机的状态是如何确定的?

Rust 编译器确定异步状态,不是先数 await,再机械地生成几个状态。

真正的过程是:

源代码
  ↓
建立控制流
  ↓
找出所有“可能暂停”的位置
  ↓
把这些位置变成“恢复点”
  ↓
分析每个恢复点之后还需要哪些局部变量
  ↓
把“恢复位置 + 需要保存的数据”编码进异步函数的内部状态

所以你问的两个核心问题:

状态怎么确定?

答案:由控制流中的可暂停点,以及从这些点恢复时必须区分的执行路径决定。

状态边界在哪里?

答案:边界出现在执行可能被挂起的地方;从一个恢复点开始,到下一个可能挂起的位置或函数结束,是下一段可继续执行的代码。

但这还不够精确。下面直接拿代码推。


1. 最简单的情况:两个 await

async fn foo() {
    A();
    let x = B().await;
    C(x);
    let y = D(x).await;
    E(y);
}

先不要想 Future,也不要想 poll。

只问一个问题:

执行到什么地方以后,函数可能暂时不执行了?

答案只有两个:

B().await
D(x).await

因为 A()、C(x)、E(y) 都必须执行完以后才能继续走下一步。

于是原来的连续执行:

A
↓
B().await
↓
C(x)
↓
D(x).await
↓
E(y)

被两个“可暂停点”切开:

A
↓
B().await
│
├── 已完成 → 继续
│
└── 未完成 → 暂停
             ↑
             |
          以后恢复
             |
             ↓
C(x)
↓
D(x).await
│
├── 已完成 → 继续
│
└── 未完成 → 暂停
             ↑
             |
          以后恢复
             |
             ↓
E(y)

所以会有三个恢复阶段:

阶段 0:
    A();
    推动 B

阶段 1:
    得到 B 的结果
    C(x);
    推动 D

阶段 2:
    得到 D 的结果
    E(y);
    完成

注意这个非常关键:

不是“两次 await,所以有两个状态”。

而是:

两个可暂停点把整个执行过程分成三个“恢复阶段”。

因此最简单情况下:

N 个独立的可暂停点
→ N + 1 个连续执行阶段

这是你要抓住的第一条规则。


2. 为什么第一次 await 前也有一个状态?

你之前一直追问这个,其实这里就能看清。

例如:

async fn foo() {
    A();
    B().await;
}

有人会误以为:

B().await = 状态 0

不是。

因为第一次调用这个异步函数时,程序必须知道:

我是第一次进入这个函数。

于是需要一个“起始状态”:

状态 0:
    A();
    推动 B

如果 B 没完成:

状态 0
   ↓
B 未完成
   ↓
保存“下一次从 B 这里继续”
   ↓
Pending

以后再次进入:

状态 1:
    B 已完成
    继续执行 await 后面的代码

所以从“恢复”的角度看:

开始执行
  ↓
第一次暂停
  ↓
第二次恢复
  ↓
第二次暂停
  ↓
第三次恢复
  ↓
完成

状态描述的是“现在重新进入这个异步函数时应该走哪条控制流”。


3. 真正关键:为什么“一个 await 后面”一定要成为新的恢复点?

因为 await 可能返回:

Poll::Pending

比如:

let x = B().await;
C(x);

假设第一次执行:

B → Pending

那么 C(x) 根本不能执行。

函数必须退出。

于是系统要记住:

“以后 B 完成以后,不要重新从 A 开始,
而是直接继续处理 B 的结果,然后执行 C。”

所以必须有一个新的恢复位置:

恢复位置 = await 后面

这就是为什么:

await
 ↓
新的恢复阶段

不是设计者随便规定,而是暂停/恢复这个机制逼出来的。


4. 现在进入真正重要的地方:if

你问的是“编译器怎么确定状态”,真正能看出答案的是分支。

async fn foo() {
    if condition {
        A().await;
        B();
    } else {
        C().await;
        D();
    }

    E();
}

控制流不是一条直线,而是:

             ┌→ A().await → B() ─┐
             │                    │
开始 → if ───┤                    ├→ E
             │                    │
             └→ C().await → D() ─┘

现在第一次暂停可能发生在:

A().await

也可能发生在:

C().await

所以恢复时必须知道:

我之前是在 A 那条路径暂停的

还是:

我之前是在 C 那条路径暂停的

因此你看:

状态数量已经不是简单地等于 await 数量。

因为这里需要区分不同的控制流路径。

概念上可能是:

状态 0:
    判断 condition

状态 1:
    等待 A
    A 完成后执行 B
    然后汇合到 E

状态 2:
    等待 C
    C 完成后执行 D
    然后汇合到 E

状态 3:
    执行 E
    完成

所以真正决定状态的东西已经显现出来:

不是源代码长什么样,而是暂停以后,恢复时需要区分哪些“继续执行的位置”。


5. 所以“状态边界”的精确定义是什么?

可以给你一个最实用的定义:

状态边界,就是异步执行可能被中断并交出执行权的位置。

在 Rust 里最常见的这种位置就是:

.await

但是一个真正的状态,不是“这个 await 本身”。

而是:

从一个恢复位置开始
↓
执行若干普通代码
↓
直到遇到下一个可能暂停的位置

例如:

A();
B().await;
C();
D();
E().await;
F();

概念上:

状态 0:
    A();
    推动 B;
    ─────────→ 可能暂停

状态 1:
    得到 B;
    C();
    D();
    推动 E;
    ─────────→ 可能暂停

状态 2:
    得到 E;
    F();
    完成

这就是状态边界。


6. 那普通的 if、loop、match 为什么有时候也会影响状态?

因为真正分析的不是“有没有 await”,而是控制流。

例如:

async fn foo() {
    loop {
        if condition {
            A().await;
        }

        B();
    }
}

这里控制流可能:

       ┌──────────────────────┐
       ↓                      │
      B()                     │
       ↓                      │
    回到 loop                 │
       │                      │
       ↓                      │
   condition                 │
    /      \                 │
   /        \                │
true       false             │
 │            │              │
 ↓            └──────────────┘
A().await

如果 condition == false,根本不暂停。

如果 condition == true,就在 A().await 暂停。

所以恢复时需要知道:

我现在处在哪个循环迭代?
我是不是正在等待 A?
恢复以后往哪里跳?

这就是为什么编译器最终处理的其实是:

控制流图,而不是简单地扫描源码数 await。


7. 再往深一层:状态其实就是“程序计数器的离散化”

这是理解 Rust 异步最有用的一个底层视角。

普通机器执行程序时,本质上一直存在:

下一条指令在哪里?

就是程序计数器。

异步函数的问题是:

我现在执行到这里
↓
我要退出了
↓
以后回来
↓
必须知道从哪里继续

所以编译器把大量可能的执行位置,压缩成几个恢复位置:

恢复位置 0
恢复位置 1
恢复位置 2
...

然后 Future 里面有类似:

当前恢复位置 = 1

下一次 poll:

根据恢复位置 1
↓
从对应控制流继续执行

所以你可以把“状态”理解成:

为了让一个已经暂停的异步函数以后能准确恢复,编译器保存的“下一步该往哪里走”。


8. 这时候你就能回答“状态到底怎么确定”

实际上可以分成三个步骤。

第一步:编译器先把代码变成控制流

例如:

A();
if x {
    B().await;
} else {
    C().await;
}
D();

变成类似:

入口
 ↓
 A
 ↓
 if
 ├── B.await ──→ D
 └── C.await ──→ D
                  ↓
                 结束

第二步:找出可以挂起的位置

这里:

B.await
C.await

是两个挂起位置。

第三步:给“恢复后应该从哪里继续”建立标识

比如概念上:

0 = 第一次进入

1 = 正在等待 B,B 完成后走 D

2 = 正在等待 C,C 完成后走 D

3 = 已完成

于是:

状态 0
 ↓
判断 x
 ↓
状态 1 或状态 2
 ↓
等待完成
 ↓
状态 3

这就是状态机。


9. 还有一个特别重要的修正:状态不是固定等于“代码段”

比如:

async fn foo() {
    let x = bar().await;
    println!("{}", x);
}

概念上我们喜欢画:

状态 0:等待 bar
状态 1:打印 x

但编译器实际并不需要真的生成:

enum State {
    WaitingBar,
    Printing,
}

它可以用各种内部表示,甚至经过优化以后,最终机器码和你想象的这个结构差别很大。

所以:

“状态”是语义上的恢复状态,不是说编译器源码里一定有一个 enum State。

我们画 enum 只是为了把编译器做的事情具象化。


10. 最后给你一个真正准确的判定标准

以后你自己分析任何 Rust async 代码,不要问:

“这里是不是一个代码块?”

也不要单纯问:

“这里是不是一个 await?”

只问两个问题:

问题一

这里执行过程中能不能暂时停下来,把控制权交出去?

能 → 这里可能形成挂起边界。

问题二

以后恢复时,必须从哪里继续?

把所有可能的“继续位置”区分开 → 就得到了状态。

所以:

状态
=
一个能够唯一确定“恢复后继续哪条控制流”的信息

而状态边界:

=
异步执行可能暂停的位置

对于最简单的线性代码:

N 个 await
→ N 个暂停点
→ 通常形成 N+1 个连续恢复阶段

一旦出现:

if
match
loop
多个控制流汇合
嵌套 async

就不能再靠“数 await”确定状态数,必须看整个控制流,以及每个暂停位置恢复时需要区分哪些路径。

这才是 Rust 编译器确定异步状态机状态的真正依据。