第 9 篇的线程模型有一个沉默的前提:线程便宜。对几百个连接它成立;对十万个连接它破产——每个线程一份 MB 级的栈、每次切换一次内核调度,一百万并发连接意味着 TB 级的栈内存,而它们 99% 的时间在等网络。这就是经典的 C10k 问题,也是 async 存在的理由:让“等待”变得几乎免费。本篇不堆 API,而是把 async 拆到你能亲手重建的程度:async fn 到底编译成了什么、Future 为什么在被驱动之前一动不动、执行器(executor)循环里真正发生的事,以及最后那条值回票价的选型准则。
async fn 编译成了什么
先看一个每个新手都会踩的坑——它其实是理解的入口:
async fn fetch(name: &str, ms: u64) -> String {
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
format!("{name} done after {ms}ms")
}
let f = fetch("lazy", 10);
println!("future created — nothing has happened yet");
println!("{}", f.await); // 函数体在这一刻才开始执行
调用 fetch 的那一行,函数体一行都没有执行。async fn 的调用不“运行函数”,它构造一个状态机并返回——这就是 Future。编译器把你的函数体重写成一台 enum 加 match 的机器:每个 .await 是一个“挂起”状态,局部变量成为状态的字段,返回结果是终态。
这个设计解释了一切。惰性:造机器不等于开机器,所以“创建了 Future 却没 await”是零成本——也是 clippy 会警告你的经典 bug(let _ = fetch(...) 什么都没干)。便宜:挂起的任务只占用状态字段那几十到几百字节,不是 MB 级的栈——十万个挂起任务只要几十 MB。以及没有魔法:状态机是第 4 篇的 enum、第 7 篇的 trait 契约的直接应用,你完全有能力自己写一个。所以,我们来写一个。
手写 Future,再手写一个执行器
Future trait 的核心只有一个方法:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct Countdown { remaining: u32 }
impl Future for Countdown {
type Output = String;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.remaining == 0 {
Poll::Ready(String::from("liftoff"))
} else {
self.remaining -= 1;
println!("polled: {} to go", self.remaining);
cx.waker().wake_by_ref(); // "好了叫我"
Poll::Pending
}
}
}
poll 是整套系统的唯一动词,返回非黑即白:Ready(v) 完工交货;Pending 没好——但不许占着 CPU 等,必须通过 cx 里的 waker 约好“有事叫我”,然后立刻交还控制权。执行器与 Future 之间的全部合同就这一句话:Pending 意味着“别叫我,我会叫你”。
执行器呢?它只是一个循环。下面这个玩具版——三十行,真的能跑:
use std::sync::Arc;
use std::task::{Wake, Waker, Context, Poll};
use std::pin::pin;
struct Spin;
impl Wake for Spin { fn wake(self: Arc<Self>) {} }
fn block_on<F: Future>(fut: F) -> F::Output {
let waker = Waker::from(Arc::new(Spin));
let mut cx = Context::from_waker(&waker);
let mut fut = pin!(fut);
loop {
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => return v,
Poll::Pending => std::hint::spin_loop(), // 真实 runtime 在此挂起线程
}
}
}
println!("{}", block_on(Countdown { remaining: 3 }));
// polled: 2 to go / polled: 1 to go / polled: 0 to go / liftoff
玩具版与 tokio 的距离只有一处,但那一处就是全部工程:真实的执行器在 Pending 时挂起线程,去 epoll/kqueue/IOCP 上睡觉,直到某个 waker 被 I/O 完成事件触发、任务重新入队。等待的成本从 CPU 时间变成零。block_on 这个名字也因此诚实:它把“异步世界”堵在当前线程上直到出结果——每一栋 async 大厦的地基,都是这么一个循环。
Runtime:tokio 登场,以及并发不等于并行
标准库刻意只提供 Future、Waker 这些零件,不提供执行器——这是 Rust 的老哲学(语言给机制,生态给实现),而生态的事实标准是 tokio。入门只需要两个符号:
#[tokio::main]
async fn main() {
let start = std::time::Instant::now();
let (a, b) = tokio::join!(fetch("a", 300), fetch("b", 500));
println!("{a} | {b} | elapsed: {:?}", start.elapsed());
// a done after 300ms | b done after 500ms | elapsed: 501ms
}
#[tokio::main] 展开来就是“建一个 runtime,把 main 的 Future 交给它 block_on”。而 join! 的结果值得盯十秒:两个 Future 总耗时 501ms,不是 800ms——它们在同一个线程上交替推进:a 挂起等 300ms 的间隙,b 在跑。并发(concurrency)不是并行(parallelism):前者是结构,后者是执行。要真正占多核,用 tokio::spawn 把任务分发给工作线程池——但注意,spawn 要求 'static,第 3 篇的两副面孔和第 9 篇的线程规则在 async 世界一字不改地继续生效。
选型准则:谁在等待,决定用谁
一条准则,两个分支:
- CPU 密集选线程。 瓶颈是核数时,async 帮不上任何忙——状态机再便宜也不能让计算变快。图像处理、压缩、科学计算:第 9 篇的 scoped threads、channel,生态里的 rayon(数据并行的三行改造),是正解。
- I/O 密集选异步。 瓶颈是“等”时——服务器、代理、爬虫、任何同时伺候成千上万连接的东西——async 把每个连接的成本从 MB 级线程栈压到字节级状态。这是 tokio 的领土。
以及 async 世界最著名的乌龙,值得用最高警惕记住:在 async 代码里调用阻塞 API——std::thread::sleep、同步文件 I/O、某些数据库驱动——会卡住整个 worker 线程,它身上成百上千个任务一起挨饿。状态机模型下“挂起”只能发生在 .await,而阻塞调用不会挂起,它就地睡觉。解法是让阻塞代码走它该走的门:tokio::task::spawn_blocking——runtime 专门为阻塞工作开的线程池,两个世界之间的那扇门。
练习,以及第 11 篇
- 跑通本篇的惰性实验:创建 Future、打印、再 await。然后写
let _ = fetch("x", 1);跑一遍程序,确认什么都没发生——把“Future 不做 await 就是空气”钉进直觉。 - 把玩具
block_on升级为“双任务”:轮流 poll 两个Countdown,直到两个都Ready。你就亲手写出了一个最小的并发执行器——并发不需要多线程,这句话从此对你有具体的形状。 - 用
tokio::join!同时“下载”三个模拟任务(sleep不同毫秒数),确认总耗时约等于最长者而非之和;然后把其中一个改成tokio::spawn,读编译器对'static的要求,理解为什么。
第 11 篇进入那道被刻意留到最后的大门:unsafe。五件只有 unsafe 才能做的事、“安全抽象”的边界该怎么画、unsafe 不关闭借用检查器这个最反直觉的事实,以及为什么成熟的 Rustacean 写的 unsafe 越来越少——不是因为害怕,而是因为生态替你把那些边界都封好了。