阶段二 · 异步语言机制

async 与 await

一句话总结

async 把一个函数/代码块变成"返回 Future 的东西";.await 则表示"在这里等这个 Future 完成,期间让出执行权".
编译器自动把顺序代码翻译成上一篇那种状态机.代价是 async 会沿调用链"传染"--这就是所谓的函数染色.

从手写 Future 到 async

上一篇我们看到 Future 是个状态机.如果每个异步任务都要手写 poll 和状态转换,没人受得了.async/await 就是为此而生的语法糖:用近乎同步的顺序写法表达逻辑,编译器替我们生成状态机.

// 看起来就像普通顺序代码
async fn fetch_user(id: u32) -> String {
let conn = connect().await; // 等连接
let row = conn.query(id).await; // 等查询
row.name // 返回结果
}

这段代码读起来是"连接→查询→返回"的直线,但编译器会把它编译成一个含三个状态(等连接,等查询,完成)的 Future--正是上一篇手画的那种状态机,只是不用手动实现.

async:制造 Future

async 可以修饰函数,也可以包裹一个代码块.它的效果统一是:把里面的代码包装成一个 Future,而不是立即执行.

// async fn:调用它得到的是 Future,不是 String
async fn greet() -> String {
String::from("hello")
}

// async 块:就地产生一个 Future 值
let fut = async {
let x = compute().await;
x + 1
};

async fn greet() -> String,它的真实返回类型不是 String,而是 impl Future<Output = String>.

也就是说:声明的 -> String 指的是"这个 Future 完成后产出 String",函数本身返回的是 Future.呼应上一篇:调用 greet() 只是造出 Future,函数体一行都还没跑.

await:推进并等待

.await 只能用在 async 上下文里.它的语义是:开始 poll 这个 Future;如果就绪,取出结果继续;如果未就绪(Pending),就把当前任务让出去,等被唤醒后从这里恢复.

async fn example() {
let data = read_file().await;
// └─────────┘ └──┘
// 要等的 Future 在此等它完成;期间让出线程给别的任务
println!("{data}"); // Future 完成后,从这里继续
}

.await 是异步代码里的"礼让点":跑到这里,如果要等,当前任务主动靠边停车,让后面的车(其他任务)先过;等自己要的东西就绪了,再重新汇入车流,从停车点继续开.
两个 .await 之间的代码则是连续执行,不会被打断的.

编译器做了什么:还原状态机

把前面的 fetch_user "脑内编译"一遍,async 的本质就彻底清晰了.编译器大致这样转换:

async fn 原始代码              编译器生成的状态机(概念)
─────────────── ──────────────────────────
let conn = connect().await; 状态0: poll connect()
let row = conn.query().await; Pending→存 waker,停在状态0
row.name 状态1: poll query()
Pending→存 waker,停在状态1
状态2: 计算 row.name → Ready(name)

每个 .await ────────────────▶ 一个可能让出的"暂停点"+ 一次状态切换

规律很清晰:每个 .await 就是状态机的一个暂停点..await 之间的局部变量(如 conn)需要跨暂停点存活,于是被编译器塞进 Future 结构体里保存--这解释了上一篇说的"现场保存在 Future 里".

这也解释了 Pin 为何存在

既然局部变量被搬进 Future 结构体,而它们之间可能存在引用(一个变量借用另一个),这个 Future 就成了自引用结构.一旦它在内存里被移动,内部引用就会指向错误地址.

Pin 的职责正是"钉住它别移动",保护这些自引用.现在这条线索闭环了.

函数染色:async 会传染

有一个绕不开的特性必须正视:.await 只能在 async 函数/块里使用.这意味着,一旦某个底层函数是 async 的,所有想 await 它的调用者也必须是 async,层层向上传染.社区称之为"函数颜色(function color)"问题--函数被染成了"同步色"和"异步色"两种,异步色会顺着调用链蔓延.

async fn low()  { /* ... */ }

async fn mid() { low().await; } // 想 await low,自己必须 async

fn top() {
mid().await; // 编译错误!top 不是 async,不能用 .await
}

同步函数和异步函数像两种插头:异步插头插不进同步插座.不能在普通 fn 里直接 .await,正如不能拿三脚插头硬插两孔插座--需要一个"转换器",而那个转换器就是运行时(下一篇)提供的入口,比如 #[tokio::main]block_on.

Go 没有这个问题,但代价不同

Go 里任何函数都能直接发起"阻塞"调用,无需染色--因为 runtime 内建,处处可挂起.Rust 选择不内建运行时(回顾第 01 篇的理由),代价就是 async 染色.

好在实践中,异步项目通常"一异步到底",染色带来的摩擦比初看时小.

异步世界的入口:总得有人 await 最外层

染色推到最顶层会遇到一个问题:main 该怎么办?main 默认是同步的,但我们需要在它里面 await 最外层的 Future.这就需要运行时提供一个"同步 → 异步"的桥:

// Tokio 提供的宏,把 main 包装成能驱动 Future 的入口
#[tokio::main]
async fn main() {
fetch_user(1).await; // 现在可以 await 了
}

#[tokio::main] 在背后创建运行时,并用它 block_on 我们的 async main.这个"运行时到底是什么,怎么驱动 Future"的问题,正是下一篇的主题.

快速回顾

  • async:把函数/代码块包装成 Future;async fn () -> T 真实返回 impl Future<Output = T>,调用时不执行函数体.
  • .await:poll 一个 Future,就绪则取值继续,未就绪则让出当前任务,被唤醒后从此处恢复.
  • 编译器生成状态机:每个 .await 是一个暂停点;跨暂停点的局部变量被存进 Future 结构体.
  • Pin 闭环:被保存的变量间引用使 Future 成为自引用结构,故需 Pin 防移动.
  • 函数染色:.await 只能在 async 上下文用,async 沿调用链传染;Go 因内建运行时无此问题.
  • 入口桥:#[tokio::main] / block_on 把同步 main 接入异步世界.

动手练习

  1. 真实返回类型:写一个 async fn add(a: i32, b: i32) -> i32,说出它的真实返回类型是什么.
  2. 复现报错:在普通 fn 里调用一个 async 函数并 .await,记录编译错误,再用 #[tokio::main] 修复.
  3. 数暂停点:给一个含两个 .await 的 async 函数,数出它的状态机有几个暂停点.
  4. 理解 Pin:解释为什么跨 .await 存活的局部变量会被放进 Future 结构体,这和 Pin 有何关系.
  5. 函数颜色:用"函数颜色"概念,说明为什么一个底层 async 函数会迫使它的所有调用者也变成 async.