async/await:让一个线程同时等很多件事

讲清 async fn 为什么只是返回 Future 的普通函数、await 与隐式状态机做了什么、为什么需要 runtime,以及跨 await 持有值为什么会编译不过

之前我们用 thread::spawn 做并发,方式是"每个任务开一个操作系统线程"。

这在很多场景下很好用,但有一个隐含的浪费:如果你要下载两个网页,那两件事绝大部分时间都在等网络,线程本身什么也没干,却各自占着一份栈内存和调度成本。

任务越碎、越多,这个浪费越明显。

异步编程换一个方向:不开新线程,而是在 一个线程上同时推进很多件事------哪件事的数据到了就接着往下走,其余时间这个线程去做别的。

async fn 就是返回 Future 的普通函数

先看语法:

rust 复制代码
async fn page_title(url: &str) -> Option<String> {
    let text = get(url).await.text().await;
    // ...
}

async fn 不是一种新的函数种类。The Rust Book 说得很明确:Rust 看到 async 标记的函数时,会把它编译成一个 非 async 的函数 ,函数体是一个 async 块;而 async 块会被编译成一个实现了 Future trait 的匿名类型。

所以上面那个函数,大致等价于:

rust 复制代码
fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move { /* 原来的函数体 */ }
}

也就是说:写 async fn,就等于写一个返回 future 的函数 。Future 是一个普通 trait,有一个关联类型 Output,就是最终算出来的值的类型。

这里的 Output 是 Option<String>,和 async fn 声明的返回类型一致。

async 会创建线程吗

这是最容易被误解的一点,先直接回答:不会。

async fn 只是编译成一个返回 future 的普通函数,调用它就是在栈上造一个状态机结构体,连堆分配都不一定有。

await 也不创建线程------它做的是"让出控制权",把当前线程交还给执行器去跑别的任务。

真正决定"用几个线程"的是 运行时 ,而不是 async 语法本身。

以 tokio 为例:它的多线程运行时默认会起一组工作线程(数量默认等于 CPU 核数),任务被分发到这些线程上执行;而它还有一个 current_thread 运行时,所有任务都在一个线程上跑。后者依然能"同时等很多件事",因为等待时不占线程。

所以三个层次要分清:

  • 语法层 (async / await):不创建线程,只是造状态机、让出控制权。
  • 运行时层:可能创建线程,也可能不创建------这是运行时的选择。
  • tokio::spawn:把一个任务交给运行时调度,它可能跑在任意一个工作线程上。

惰性:和迭代器是同一个设计

接下来这一点是理解全局的关键。Book 的原话是:future 是惰性的,除非你用 await 明确要求它跑,否则它什么也不做;如果你拿到一个 future 却不管它,编译器还会警告你。

迭代器那篇里,map、filter 什么都不做,只有遇到 sum、collect、for 这样的消耗型方法才真正跑起来。

future 的规则一模一样:调用一个 async fn 不执行它的函数体 ,只是造出一个"待办事项";不 await,它就一直躺着。

迭代器不调 next 就什么也不做,future 不 await 就什么也不做。

这个设计有实际好处:你可以先造出一批 future,再决定怎么处理它们。

await 做了什么

await 的作用可以拆成两条。

第一,它 推进 这个 future:检查它算完了没有。这个检查动作有个术语叫 poll(轮询)。

第二,如果还没算完,它把 控制权交还给运行时,而不是死等。

这就是它能同时处理很多件事的原因------一个任务在等网络时,线程可以去干别的任务。

于是每一个 await 的位置,都是一个"这个函数可能在这里暂停、之后再从这里恢复"的点,Book 直接称它为 await 点。

那"恢复"是怎么做到的?靠编译器把 async 块变成一个 状态机 。它大致像一个 enum:每个 await 点对应一个变体,而 跨越 await 点还活着的那些变量,就是这个变体的字段。

rust 复制代码
async fn bar(x: &Mutex<u32>) {
    let g = x.lock().unwrap();
    baz().await          // ← await 点
}                        // ← g 到这里才被释放

对应的生成物大致是:

rust 复制代码
enum BarGenerator {
    Unresumed { x: &Mutex<u32> },
    Suspend0 { baz_future: BazGenerator, x: &Mutex<u32>, g: MutexGuard<'_, u32> },
    Returned { value: () },
}

暂停时要保存现场,x 和 g 都得存进 Suspend0 这个变体里,等恢复了再取出来。跨 await 点活着的值,实际上成了 future 这个类型的字段。

谁来推进它:runtime

既然 future 自己不跑,那总得有人去 poll 它------一层层 await 往上,最终还是要有个东西驱动最外层那个 future。

这个角色叫 运行时 (runtime),其中负责执行 async 代码的部分叫 执行器(executor)。

这也解释了很多人第一次写 async 时会撞上的问题:为什么 main 不能标成 async 。试想如果 main 是 async 函数,那它返回的 future 也得有人管,可 main 是程序的起点,它上面没有别人了。

所以标准做法是在 main 里先建好运行时,再把 future 交给它跑。常见的 #[tokio::main] 宏做的就是这一件事。

为什么跨 await 持有某个值会编译不过

既然跨 await 活着的值会成为 future 的字段,那么 这个 future 能不能被送到某个地方,就取决于它带着哪些字段。两个最常见的报错都出在这里。

一是生命周期。 async fn foo(x: &T) 返回的 future 里装着 x 这个引用,于是这个 future 的类型带着 x 的存活期------它必须在 x 还有效的时候被 await。紧跟着 await 没问题(foo(&x).await),但如果你想把它存起来,或者丢给别的任务,借用关系就断了。

二是 Send。 上一篇讲过,把任务交给多线程执行器,本质上就是把它送到别的线程去,所以要求 future 是 Send 。可如果它里面装着 Rc 、&RefCell ,或者 std::sync::MutexGuard ,整个 future 就不再是 Send:

text 复制代码
error[E0277]: `std::sync::MutexGuard<'_, u32>` cannot be sent between threads safely
note: future does not implement `Send` as this value is used across an await

编译器甚至能指出是哪个值、以及"它就活在这一行 await 上"。修法通常不是换类型,而是 缩短它的存活范围 ------用一对花括号把那个值包起来,让它在 await 之前就销毁:

rust 复制代码
async fn foo() {
    {
        let x = NotSend::default();
    }        // ← 在这里就销毁了
    bar().await;
}

async 还是线程

两种方式解决的不是同一个问题。

线程适合 计算密集 的活:真在算东西,那就该占一个核。

async 适合 等待密集 的活:网络、文件、数据库,任务大部分时间在等。

代价也要说清楚:future 的惰性和那套状态机展开,让报错经常绕、调试也更费劲;运行时不是标准库的一部分,你得自己选一个(tokio 最常用);库和运行时还得能对上。

所以"该不该用 async"往往不是理念问题,而是先看清手里这个程序是在 等 ,还是在 算。


术语表

术语 含义
Future 一个普通 trait,带关联类型 Output ,表示"现在还没算好、将来会算好的值";async 块和 async fn 都被编译成实现了它的匿名类型。
poll 检查一个 future 是否已经算好的动作,就是 await 背后真正发生的事。
await 点 代码里每一处 await,都是这个 async 块可能暂停、之后再恢复的位置。
隐式状态机 编译器把 async 块展开成的匿名类型,形状像 enum,每个 await 点一个变体,跨点存活的变量是其字段。
运行时(runtime) 负责 poll 并推进 future 的外部组件;不是标准库的一部分,需要自行选择。
执行器(executor) 运行时里真正执行 async 代码的那部分。
block_on 接收一个 future 并在当前线程上把它跑到完成的函数,常用来在普通 main 里搭起最外层的驱动。
Send 交叉约束 一旦 future 要交给多线程执行器,它跨 await 持有的每个值都必须能安全地送到别的线程,否则整个 future 不是 Send。

参考来源

相关推荐
迅猛龙办公室1 小时前
Python实现绘制同切圆
开发语言·python
弹简特2 小时前
【Java项目-企悦抽】16-抽奖模块01-获取活动完整信息接口实现
java·开发语言·状态模式·springboot
言乐62 小时前
Python语音检索
开发语言·python·django·virtualenv·pygame
BD_Marathon2 小时前
多轮对话聊天机器人
开发语言·机器人·c#
Sarvartha2 小时前
接口基础知识
java·开发语言
沐晓时光3 小时前
C语言入门,深入理解指针(1)
c语言·开发语言
AC赳赳老秦3 小时前
OpenClaw 数据引用规范自动生成:为公开数据构建可信来源标注与标准引用体系
大数据·开发语言·汇编·数据库·人工智能·deepseek·openclaw
无忧.芙桃3 小时前
C++语言原理与实践(十):vector类的底层实现
开发语言·c++·算法
变与不变8064 小时前
js同步和异步难点重点详解
开发语言·javascript·ecmascript