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 对所有权和内存安全的约束。