async 基础
本章讲 Rust 的异步(async)编程:
async fn语法糖背后的状态机、Future与Pin、外部运行时 (Tokio)以及高并发 IO 的实战写法。前置知识:〈泛型与生命周期〉一章的 trait 与生命周期、〈智能指针与闭包〉一章的闭包、 并发编程 的Send/Sync与线程模型。
本章目标
- 能说清线程模型在高并发 IO 场景下的三项成本(栈内存、上下文切换、C10K 问题),并解释 事件循环(event loop)与
epoll/IOCP为什么能绕开它们。 - 能理解
async fn返回的是impl Future<Output = T>,.await的本质是「编译期把函数切成状态机」, 并知道Pin存在的唯一理由是防止自引用状态机被移动。 - 能用 Tokio 写出可运行的 TCP 回声服务器、并发请求、超时与并发限流。
- 能正确选择
join!/try_join!/spawn/select!,并理解「取消 = drop future」与 取消安全(cancel safety)的含义。 - 能避开 7 类常见陷阱:
'static捕获、跨.await持MutexGuard、阻塞 runtime、 忘记.await、递归 async、trait 里的 async fn 不保证Send。 - 能判断一个项目该不该用 async。
为什么需要 async
线程模型的成本
在 并发编程 里我们用 std::thread 解决并发:一个连接一个线程, 写起来像同步代码,非常直观。问题是这种模型的成本随并发的连接数线性增长:
| 成本项 | 量级(x86_64 Linux/Windows 默认值) | 并发 1 万连接时 |
|---|---|---|
| 线程栈内存 | 默认保留 2 MiB(Linux 8 MiB 虚拟) | ≥ 20 GB 虚拟地址空间 |
| 内核调度结构 | 每线程一份内核栈 + task_struct | 内核对象数量爆炸 |
| 上下文切换 | 1~5 µs,且切换时 CPU 缓存失效 | 调度器大部分时间在切换 |
| 同步成本 | 锁竞争、伪共享(false sharing) | 吞吐随核数下降 |
关键观察:处理一个网络请求时,线程 99% 的时间在等 IO(等网卡、等磁盘),CPU 是空闲的。 用宝贵的 OS 线程去「等」,是这类程序最大的浪费。
🧠 原理:C10K 问题(C10K problem)指「单机同时处理 1 万个连接」,由 Dan Kegel 在 1999 年 提出。当年用「一连接一线程」根本无法完成,今天也不是不能做——只是内存和调度开销让你在 1 万连接时就把机器吃满,而 CPU 利用率仍然很低。
阻塞 vs 非阻塞 IO
- 阻塞 IO(blocking IO):
read系统调用在数据到达前不让出 CPU,线程被内核挂起。 同步代码好写,但线程被占住。 - 非阻塞 IO(non-blocking IO):
read立刻返回WouldBlock,调用者需要自己去问 「什么时候可读」。
WouldBlock 不能靠「循环重试」解决——那叫忙等(busy-wait),会把一个 CPU 核烧到 100%。 正确的做法是把「谁可读了」这件事交给内核:注册一批文件描述符(file descriptor,fd), 让内核在有事件时通知你。这就是 IO 多路复用(IO multiplexing)。
💡 对照:JS 的
fetch、Pythonasyncio的await reader.read()背后都是这一层。 Rust 的独特之处是:标准库不绑定任何一种 IO 多路复用实现,它把这件事留给了运行时。
一句话原理:
- Linux:
epoll维护一张被监视的 fd 集合,epoll_wait一次性返回所有就绪的 fd。 - Windows:
IOCP(IO Completion Port,IO 完成端口)不用「就绪通知」而用「完成通知」, 提交异步 IO 请求后内核完成时把结果投递到完成队列。 - macOS/BSD:
kqueue。
🧠 原理:
epoll是就绪通知(可读/可写了,你自己去读),IOCP是完成通知 (数据已经读进你给的缓冲区了)。这个差别解释了为什么 Tokio 在 Windows 上的实现细节 和 Linux 不同,但对使用者完全透明。
事件循环(event loop)
有了 IO 多路复用,一个线程就能管理上万个连接:
text
事件循环(单线程即可)
┌──────────────────────────────────────────────────┐
│ 1. 问内核:哪些 fd 就绪了? ← epoll_wait / IOCP │
│ 2. 对每个就绪事件,唤醒「等它的任务」 │
│ 3. 让这些任务继续跑一小段(直到它再次要等 IO) │
│ 4. 回到 1 │
└──────────────────────────────────────────────────┘「任务」不是 OS 线程,而是用户态的轻量单元:任务保存自己的执行进度,遇 IO 就让出线程。 保存执行进度这件事,在 JS/C# 里由运行时维护调用栈,在 Rust 里则由编译期生成的状态机完成。
Rust async 的定位
Rust 的三条设计选择,决定了它和 JS / Go / C# 都不同:
- 编译期生成状态机:每个
async fn被编译成一个匿名类型,enum的每个变体对应一个.await点。没有堆分配的协程栈,没有 GC。 - 标准库没有运行时(runtime):
std只提供Future、Pin、Waker这些接口, 不提供调度器、不提供epoll封装、也没有block_on。你不引入运行时,就没有异步 IO。 - 需要 executor:谁把
Future一遍遍poll到完成,谁就是 executor(执行器)。 Tokio、async-std、smol 都是第三方 crate,可以按场景替换。
⚠️ 陷阱:新手最常见的困惑是「我
cargo add了tokio,为什么异步 IO 还要开 feature?」 因为 Tokio 把rt(运行时)、net(网络)、fs(文件)、time(定时器)拆成了独立 feature, 编译期按需裁剪。用features = ["full"]是学习期最省事的选择。
async/await 的本质
async fn 返回 impl Future
rust
// async fn 是语法糖:返回一个实现了 Future 的匿名类型
async fn add(a: u32, b: u32) -> u32 {
a + b
}
// 上面这句大致等价于下面这个签名(返回类型由编译器生成,写不出来)
fn add_desugared(a: u32, b: u32) -> impl std::future::Future<Output = u32> {
async move { a + b } // async 块同样返回 impl Future
}要点:
async fn f() -> T的返回类型是impl Future<Output = T>,不是T。async { ... }是异步块,同样得到一个impl Future。- 两种写法都惰性(lazy):调用它们不会执行函数体里任何一行代码。
惰性:不 await 就不执行
Future 是懒惰的,这一点和 JS 的 Promise 完全不同——Promise 一旦创建就开始跑了:
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::Instant;
async fn fetch(name: &str) -> String {
// 这一行只有被 poll 到才会真的睡眠
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
format!("body of {name}")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
// 只构造了 future,没有任何字节被处理:耗时 0
let pending = fetch("A");
println!("构造后耗时:{:?}", start.elapsed());
// 手动轮询一次才会真正开始;真实代码里用 .await 或交给 executor
let body = pending.await;
println!("await 后耗时:{:?},得到 {body}", start.elapsed());
}输出:
构造后耗时:0ns(或极小的值),await 后耗时:100ms 左右,得到 body of A。
💡 对照:JS 的
new Promise(...)会立即执行;Rust 的 future 是「按需驱动」的。 好处是 future 可以被取消(还没跑就 drop 掉),代价是忘记.await时什么都不会发生, 而且只得到一条警告(见「忘记.await:future 根本没执行」)。
Future trait 的定义
rust
// std::future 的真实定义(简化了 unsafe 部分);这是接口声明,不构成可运行程序
pub trait Future {
type Output;
// 关键:&mut self,因为轮询要推进内部状态
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T), // 完成了,值是 T
Pending, // 还没好,稍后再 poll
}Context 里装着 Waker:当 future 返回 Pending 时,它必须把 cx.waker() 存起来, 并在自己可以继续时调用 wake(),否则 executor 永远不知道要再 poll 它(任务「睡死」)。
状态机展开示意
async fn 的函数体被编译器改写成 enum。以下面这段为例:
rust
async fn download_and_save(url: &str) -> std::io::Result<usize> {
let body = fetch(url).await; // 挂起点 1
let saved = save(&body).await; // 挂起点 2
Ok(saved)
}编译器生成的类型大致等价于(示意,不是真实展开):
text
enum DownloadFuture<'a> {
Start { url: &'a str },
WaitingFetch { fut: FetchFuture<'a> },
WaitingSave { body: String, fut: SaveFuture<'a> },
Done,
}
每次 poll 相当于:
Start → 取出 fut = fetch(url),转入 WaitingFetch,返回 Pending
WaitingFetch → poll(fut):
Pending → 原样返回 Pending
Ready(body) → body 存进自身,取出 fut = save(&body),
转入 WaitingSave,返回 Pending
WaitingSave → poll(fut):
Pending → 返回 Pending
Ready(n) → 转入 Done,返回 Ready(Ok(n))ASCII 版控制流:
text
调用 download_and_save(url) ← 什么都不执行,只构造状态机
│
▼
┌───────────────────┐
│ Start │ poll ──► 启动 fetch,Pending
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ WaitingFetch │ poll ──► fetch 完成,body 落在状态机里
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ WaitingSave │ poll ──► save 完成
└─────────┬─────────┘
│
┌─────────▼─────────┐
│ Done(Output) │ poll ──► Ready(Ok(n))
└───────────────────┘两个直接结论:
.await是挂起点:跨.await存活的局部变量都会成为状态机的字段。所以 「跨.await持有什么」会直接影响 future 的Send。- 状态机的类型大小 = 所有变体字段的最大占用,这个大小在编译期就是确定的,不需要堆分配。
Pin:为什么状态机不能移动
async 生成的状态机可能是自引用的。看这个段:
rust
// 只展示状态机里会出现「自引用」这件事,不构成可运行程序
async fn demo() {
let s = String::from("hello");
let r: &str = &s; // 指向 s
other().await; // 挂起点:s 和 r 都要存进状态机
println!("{r}"); // 醒来后继续用 r
}展开后,状态机里同时有「String 本体」和「指向本体内部的引用」。如果这个状态机在内存里 被移动,内部引用就会指向旧地址,形成悬垂指针(dangling pointer)。而 Rust 的默认语义是 「值可以被随意移动」——所以需要一个类型系统层面的「不许移动」标记。
这就是 Pin<P>:一个指针包装,承诺「它指向的值在被 drop 之前不会再被移动」。 一句话版本:
Pin<&mut Self>保证*self的位置固定;当Self: Unpin(可以安全移动)时,Pin<&mut Self>可以像普通&mut Self一样用;当Self: !Unpin时,你拿不到&mut Self, 因此结构上无法移动它。
rust
// Pin 的比喻用:非 Unpin 类型无法从 Pin 里取回 &mut,也就无法被移动
use std::marker::PhantomPinned;
use std::pin::Pin;
#[derive(Debug, Default)]
struct SelfRef {
data: String,
ptr: *const u8,
// PhantomPinned 让 SelfRef 变成 !Unpin,模拟「里面有指向自己的指针」
_pin: PhantomPinned,
}
fn main() {
let s = SelfRef { data: String::from("hi"), ptr: std::ptr::null(), _pin: PhantomPinned };
let boxed = Box::pin(s); // 值被固定在堆上
let _ok: &SelfRef = &*boxed; // 共享借用没问题
// let moved = std::mem::take(&mut *boxed); // 取消注释会编译失败
}取消注释后,rustc 报的真实错误是:
text
error[E0596]: cannot borrow data in dereference of `Pin<Box<SelfRef>>` as mutable
|
17 | let _moved = std::mem::take(&mut *boxed);
| ^^^^^^^^^^^ cannot borrow as mutable
|
= help: trait `DerefMut` is required to modify through a dereference,
but it is not implemented for `Pin<Box<SelfRef>>`🧠 原理:
Pin不是运行时检查,也不是「锁」。它纯粹是把「不许移动」编码进类型系统:Pin<P>只有在P::Target: Unpin时才实现DerefMut。所以对!Unpin的 future, 所有需要移动的 API 都不再可用,你只能通过Pin提供的少数方法访问它。
实践中你不需要手写 Pin:
| 场景 | 写法 |
|---|---|
| 普通 async 代码 | 不用管,.await 时编译器自动 pin |
在 main / 测试里驱动 future | #[tokio::main] / block_on,内部已 pin |
需要 Box 一个 future | Box::pin(fut) |
| 需要在结构体里存 future | 字段类型写 Pin<Box<dyn Future<Output = T> + Send>> |
自己实现 Future | 需要写 fn poll(self: Pin<&mut Self>, ...) |
🚀 进阶:手动
impl Future一次。理解Waker的用法后,你会发现所有 async 框架 都是这个模式的放大版。
rust
// 依赖:无(纯 std)。目标:手动实现一个可被 block_on 驱动的倒计时 future
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct Countdown {
from: u32,
}
impl Future for Countdown {
type Output = u32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.from == 0 {
Poll::Ready(0) // 完成:返回 Output
} else {
self.from -= 1;
// 真实轮子里这里把 waker 注册到 IO 事件源;
// 示例里没有事件源,只能立刻自唤醒,让我们下次还能被 poll
cx.waker().wake_by_ref();
Poll::Pending // 未完成:必须先安排好「谁来唤醒我」
}
}
}
// 需要一个 executor 才能跑起来;最小 block_on 见「最小 `block_on`:自己写一个 executor」
async fn use_countdown() -> u32 {
Countdown { from: 3 }.await
}
fn main() {
// 这里只是证明 Countdown 是一个合法的 Future,能被传给任何 executor
fn assert_future<F: Future<Output = u32>>(_f: F) {}
assert_future(Countdown { from: 3 });
assert_future(use_countdown());
println!("ok");
}输出:
ok。
如果每次手写 impl Future 都太啰嗦,标准库的 poll_fn(1.64 稳定)能把「一次轮询逻辑」 写成一个闭包:
rust
// 依赖:无。poll_fn 把「手写 poll」压缩成一个闭包
use std::future::poll_fn;
use std::task::Poll;
async fn sum_three(a: u32, b: u32, c: u32) -> u32 {
// 闭包捕获 step:状态就放在闭包里,不用自己定义结构体
let mut step = 0u32;
poll_fn(move |_cx| {
step += 1;
if step == 1 {
Poll::Ready(a + b + c)
} else {
Poll::Pending
}
})
.await
}
fn main() {
fn assert_future<F: std::future::Future<Output = u32>>(_f: F) {}
assert_future(sum_three(1, 2, 3));
println!("ok");
}运行时(runtime)
为什么标准库不带 executor
Rust 的目标之一是「能在没有操作系统的环境里跑」(嵌入式、内核、WASM)。调度策略、 IO 多路复用的选择、是否有多线程,都强依赖目标平台和业务场景。把这些塞进 std 会让所有程序被迫接受一份运行时。所以 std 只给接口:
std 提供(接口层) | 谁来提供(实现层) |
|---|---|
Future、Poll、Context、Waker | 运行时:tokio / async-std / smol / embassy |
Pin、Unpin | 同上 |
std::task::Wake(1.51) | 自定义 waker 时可省掉 RawWaker 样板代码 |
最小 block_on:自己写一个 executor
futures crate 的 block_on 只有一行,但要理解它为什么能工作,最好亲手写一个 60 行的版本。 下面这段只依赖 std(main 可编译可运行),它展示 executor 的三件事: 持有 waker → 循环 poll → Pending 时睡觉。
rust
// 依赖:无。目标:一个只支持「自己会唤醒自己」的 future 的最小 executor
use std::future::Future;
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
struct Parker {
awake: Mutex<bool>,
cv: Condvar,
}
impl Parker {
fn new() -> Self {
Parker { awake: Mutex::new(false), cv: Condvar::new() }
}
fn park(&self) {
let mut awake = self.awake.lock().unwrap();
// 被唤醒前一直睡;醒来后必须清标记,否则下一轮 park 会直接穿过
while !*awake {
awake = self.cv.wait(awake).unwrap();
}
*awake = false;
}
}
// Waker 是「裸函数指针 + 数据指针」,这里用 Arc<Parker> 当数据
fn clone_arc(data: *const ()) -> RawWaker {
let arc = unsafe { Arc::from_raw(data as *const Parker) };
let cloned = Arc::clone(&arc);
std::mem::forget(arc); // 只借不拿,必须 forget 以免计数出错
RawWaker::new(Arc::into_raw(cloned) as *const (), &VTABLE)
}
fn wake_arc(data: *const ()) {
let arc = unsafe { Arc::from_raw(data as *const Parker) };
let mut awake = arc.awake.lock().unwrap();
*awake = true;
arc.cv.notify_one();
}
fn drop_arc(data: *const ()) {
unsafe { drop(Arc::from_raw(data as *const Parker)) };
}
static VTABLE: RawWakerVTable = RawWakerVTable::new(clone_arc, wake_arc, wake_arc, drop_arc);
fn block_on<F: Future>(fut: F) -> F::Output {
let parker = Arc::new(Parker::new());
let raw = RawWaker::new(Arc::into_raw(Arc::clone(&parker)) as *const (), &VTABLE);
// 这一处 unsafe 是 std 的接口要求,不是我们的逻辑需要
let waker = unsafe { Waker::from_raw(raw) };
let mut cx = Context::from_waker(&waker);
let mut fut = Box::pin(fut); // future 必须被 pin 在稳定地址上
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v, // 完成就交出结果
Poll::Pending => parker.park(), // 没完成就睡,醒来再 poll
}
}
}
async fn add(a: u32, b: u32) -> u32 {
a + b
}
fn main() {
// add 里没有真正的 IO,第一次 poll 就 Ready
println!("{}", block_on(add(1, 2)));
}输出:
3。
对应到 futures crate,日常只需要:
rust
// 依赖:futures = "0.3"
fn main() {
let total = futures::executor::block_on(async {
let a = 1 + 1;
a * 21
});
println!("{total}");
}输出:
42。
⚠️ 陷阱:
futures::executor::block_on没有 IO 驱动。它只能驱动「自己会唤醒自己」 的 future(如futures::channel),拿来跑tokio::net::TcpStream会直接挂死。 生产代码用 Tokio,不用futures的 executor。
Tokio 入门
Tokio 是生态事实标准:成熟、文档齐、几乎所有 Web/数据库 crate 都以它为前提。
toml
# Cargo.toml
[package]
name = "async-demo"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["full"] }rust
// 依赖:tokio = { version = "1", features = ["full"] }
// #[tokio::main] 把 async main 包成「建运行时 + block_on」
#[tokio::main]
async fn main() {
println!("hello from tokio");
}#[tokio::main] 是属性宏,展开后大致是:
rust
fn main() {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build Tokio runtime")
.block_on(async { /* 你的 async main 函数体 */ })
}三种常用形态:
rust
// 依赖:tokio = { version = "1", features = ["full"] }
#[tokio::main] // 默认 multi-thread 运行时
async fn main() {}
#[tokio::main(flavor = "current_thread")] // 单线程运行时,适合测试与轻量 CLI
async fn main_single() {}
#[tokio::test] // 异步测试:默认 current_thread
async fn works() {
assert_eq!(2 + 2, 4);
}multi-thread vs current_thread:
| 维度 | multi_thread(默认) | current_thread |
|---|---|---|
| worker 线程数 | 默认 = CPU 核数 | 1 |
| 并行执行任务 | 是(真并行,需要 Send) | 否(同一时刻只跑一个任务) |
需要 Send 的 future | 是,tokio::spawn 要求 'static + Send | 否(可以 spawn !Send) |
| 适用 | 服务器、高吞吐 | 测试、单线程工具、需要访问线程局部数据 |
tokio::spawn 与 JoinHandle
rust
// 依赖:tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
// spawn 把任务立刻交给运行时(与 future 的惰性相反:spawn 是"提交即开始")
let handle: tokio::task::JoinHandle<u32> = tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7 * 6
});
// handle 自己也是一个 Future,await 它拿到 JoinResult
let value = handle.await.expect("task panicked");
println!("{value}");
}输出:
42。
JoinHandle<T> 的规则:
spawn要求F: Future + Send + 'static,F::Output: Send + 'static。'static:任务可能在任何 worker 线程上跑,也可能在函数返回后才跑完, 因此不能借用栈上数据。Send:任务可能被调度到另一个线程。
handle.await返回Result<T, JoinError>:任务是 panic 了、还是被取消了,都在JoinError里。- drop 掉
JoinHandle不会取消任务——任务照跑到结束(除非运行时关闭)。
💡 对照:Go 的
go f()没有返回值;Tokio 的spawn给你一个JoinHandle, 语义上更接近 Java 的Future+ 线程池submit。
join! / try_join! / select!
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::Duration;
async fn task(name: &str, ms: u64) -> String {
tokio::time::sleep(Duration::from_millis(ms)).await;
format!("{name} done")
}
#[tokio::main]
async fn main() {
// join!:并发等待,全部完成才返回;返回元组
let (a, b) = tokio::join!(task("A", 100), task("B", 50));
println!("{a} / {b}");
// try_join!:任意一个返回 Err 就立刻返回 Err,其余 future 被 drop
let both: Result<(String, String), std::io::Error> =
tokio::try_join!(fallible(false), fallible(false));
println!("{both:?}");
// select!:谁先完成就用谁,其余分支的 future 被 drop
tokio::select! {
v = task("slow", 200) => println!("first: {v}"),
v = task("fast", 20) => println!("first: {v}"),
}
}
async fn fallible(fail: bool) -> Result<String, std::io::Error> {
if fail {
Err(std::io::Error::other("boom"))
} else {
Ok(String::from("ok"))
}
}输出:
A done / B done、Ok(("ok", "ok"))、first: fast done。
三者的关键区别:
| 是否并发 | 借用局部变量 | 需要 'static + Send | 错误处理 | |
|---|---|---|---|---|
join! | 是(同一个任务内) | 可以 | 否 | 全部完成,逐个看 Result |
try_join! | 是(同一个任务内) | 可以 | 否 | 首个 Err 立刻短路 |
spawn | 是(不同任务,可跨线程) | 不可以 | 是 | JoinHandle 返回 JoinError |
select! | 是(竞速) | 可以 | 否 | 只取第一个完成的分支 |
其他运行时与「不能混用」
async-std:API 模仿std(async_std::fs、async_std::net),一度很流行, 但近年维护放缓;新项目基本选 Tokio。smol:极小的运行时,可拆解、可组合,适合库作者或资源受限场景。它与async-executor配套,smol::block_on同样不能驱动 Tokio 的 IO 资源。
⚠️ 陷阱:在 Tokio 的 worker 线程里调用另一个运行时的
block_on会 panic。 典型错误写法与真实 panic:
rust
// 依赖:tokio = { version = "1", features = ["full"] }, futures = "0.3"
#[tokio::main]
async fn main() {
// 错:在 Tokio 运行时里再开一个 futures executor
futures::executor::block_on(async {});
}text
thread 'main' panicked at ...:
Cannot start a runtime from within a runtime. This happens because a function
(like `block_on`) attempted to block the current thread while the thread is
being used to drive asynchronous tasks.同理,Tokio 自己的 Runtime::block_on 也不能嵌套调用:
text
thread 'main' panicked at ...:
Cannot block the current thread from within a runtime. This happens because a
function attempted to block the current thread while the thread is being used
to drive asynchronous tasks.正确做法:需要阻塞时用 tokio::task::spawn_blocking,或者把整个 block_on 挪到 main 最外层(只留一个运行时)。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。