Rust 的 async 不是“把同步代码自动变快”的魔法。它更像是一套协作式调度模型:把一个可能等待 I/O 的任务拆成可以暂停和恢复的状态机,让线程在等待期间去处理别的工作。
理解这一点之后,很多问题都会变得清楚:为什么 async fn 返回的是 Future,为什么需要运行时,为什么不能随便在异步函数里阻塞线程,以及为什么有些变量跨过 .await 之后会让编译器变得很严格。
Future 是惰性的
在 Rust 里,调用一个 async fn 不会立刻执行函数体,而是创建一个实现了 Future 的值。这个值描述了一段“将来可以推进”的计算。
async fn fetch_user() -> String {
"Dor".to_string()
}
fn main() {
let fut = fetch_user();
// 到这里为止,fetch_user 的函数体还没有真正跑完。
}
Future 只有被 executor 轮询时才会向前推进。.await 的含义也不是“开一个新线程等结果”,而是“把当前任务暂停,等这个 Future 可以继续时再恢复”。
可以把 Future 粗略理解成这样:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
当 poll 返回 Poll::Pending,任务暂时让出执行权;当返回 Poll::Ready(value),这个异步计算就完成了。
async fn 会被编译成状态机
下面这段代码看起来像顺序执行:
async fn run() {
let user = fetch_user().await;
println!("{user}");
}
但编译器会把它转换成一个状态机。每一个 .await 都可能成为暂停点,暂停点之前还要在之后继续使用的变量,会被保存在这个状态机里面。
这也是 Rust async 经常出现生命周期和所有权问题的原因:如果一个引用跨过 .await,编译器必须确认这个引用在任务恢复时仍然有效。
async fn example(value: &str) {
do_something().await;
println!("{value}");
}
这段代码本身不一定有问题,但它说明了一个关键事实:.await 不是普通函数调用,它会改变变量被保存和恢复的方式。
运行时负责调度
Rust 标准库定义了 Future,但没有内置完整的异步运行时。真正负责调度任务、处理定时器、网络 I/O、文件事件的,通常是 Tokio、async-std 或 smol。
Tokio 是生产环境中最常见的选择:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let body = reqwest::get("https://example.com")
.await?
.text()
.await?;
println!("{body}");
Ok(())
}
这里的 #[tokio::main] 会生成一个运行时,然后在这个运行时里执行 main 返回的 Future。
并发不是并行
async 很适合 I/O 密集型任务。比如同时请求多个接口:
let user = fetch_user();
let posts = fetch_posts();
let (user, posts) = tokio::join!(user, posts);
join! 让两个 Future 在同一个任务上下文里交替推进。它提供的是并发,不一定是多线程并行。
如果想把任务交给运行时调度,可以用 tokio::spawn:
let handle = tokio::spawn(async move {
expensive_io().await
});
let result = handle.await?;
被 tokio::spawn 的任务通常要求是 'static,因为运行时无法保证它什么时候结束。这个约束不是任性,而是为了避免任务引用已经离开的栈变量。
不要在 async 里阻塞线程
最常见的错误之一,是在 async 函数里调用阻塞操作:
async fn bad() {
std::thread::sleep(std::time::Duration::from_secs(1));
}
这会阻塞当前运行时工作线程,让它无法继续调度别的异步任务。正确做法是使用异步版本:
async fn good() {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
如果必须执行 CPU 密集或阻塞式任务,可以考虑 spawn_blocking:
let result = tokio::task::spawn_blocking(|| {
heavy_calculation()
}).await?;
这会把阻塞任务放到专门的阻塞线程池里,避免拖住核心异步调度线程。
Mutex 的选择很重要
在异步代码里共享状态时,很多人会直接使用 std::sync::Mutex。它不是完全不能用,但需要小心:不要持有同步锁跨过 .await。
// 反例:锁跨过 await,容易造成调度问题。
let mut guard = state.lock().unwrap();
do_something().await;
guard.push(1);
更好的写法是缩小锁的作用域:
{
let mut guard = state.lock().unwrap();
guard.push(1);
}
do_something().await;
如果确实需要异步等待锁,可以使用 tokio::sync::Mutex。但它也不是默认更好;如果临界区很短,而且不会跨 .await,同步 Mutex 往往更简单。
Select 处理竞争和取消
tokio::select! 可以等待多个异步分支,哪个先完成就执行哪个:
tokio::select! {
_ = shutdown_signal() => {
println!("shutdown");
}
result = handle_request() => {
println!("request result: {result:?}");
}
}
需要注意的是,没被选中的分支通常会被取消,也就是对应的 Future 会被 drop。Rust 的取消模型很直接:drop 掉 Future,就是取消它。
因此,写异步代码时要保证被取消也是安全的。比如不要把一个协议写到一半,却没有清理状态;不要在关键事务中间随意暴露取消点。
实践建议
写 Rust async 时,我通常会遵守几条规则。
第一,先判断问题是不是 I/O 密集。如果是网络服务、数据库访问、消息队列、定时任务,async 很合适。如果是纯 CPU 计算,async 通常不是主要答案。
第二,不要在 .await 两侧保留太复杂的借用关系。必要时把数据 clone 成拥有所有权的值,或者提前把需要的字段取出来。
第三,锁、文件句柄、数据库事务这类资源不要随便跨 .await。如果必须跨,就明确知道取消和并发下会发生什么。
第四,给任务边界取清楚名字。一个大的 async 函数如果塞满业务逻辑、I/O、重试、超时和状态修改,调试起来会很痛苦。
小结
Rust async 的核心不是语法,而是模型:
async fn生成惰性的Future.await是暂停点- executor 负责轮询和恢复任务
- 运行时负责 I/O、定时器和任务调度
- 阻塞操作、锁和取消语义需要格外小心
掌握这些之后,Tokio、Axum、Reqwest、SQLx 这类库都会变得更容易理解。Rust async 的学习曲线不低,但它给出的回报也很明确:在高并发 I/O 场景里,用相对少的线程承载大量任务,同时仍然保留 Rust 对所有权和内存安全的约束。