Rust 异步是什么:Future 不自己跑

Rust 做异步的方式和很多语言不同:用 async 写出来的东西只是一个 Future(未来值),它自己不会运行。你必须把它交给一个运行时(如 tokio)去"驱动",它才会真正往前推进。这个设计把"定义异步逻辑"和"执行异步逻辑"彻底分开了。

async fn hello() -> u32 {
    1 + 1
}

fn main() {
    let future = hello();   // 注意:此时 hello 还没跑!
    // 需要运行时 poll 它,才会执行
}

这个"Future 是惰性的"特性初看别扭,但好处是 Rust 没有 GC 也能做到极轻量:成千上万个 Future 只是等待中的状态,运行时按需驱动它们,内存开销远小于每任务一个 OS 线程。

Rust 的 Future 像一张"待执行的配方":写出来不等于跑起来。真正让它跑的,是 tokio 这样的运行时。
本文核心知识点
  • async fn定义一个异步函数,返回一个 Future,默认不执行。
  • await在 async 上下文里等待一个 Future 的结果,不阻塞线程。
  • tokio最常用的 Rust 异步运行时,负责驱动所有 Future。
  • spawn把一个 Future 丢进运行时,让它在后台并发跑。
Rust 异步运行时配图:蓝绿渐变背景上多条异步任务车道(青蓝紫)从四周汇入中央一个发光的反应堆核心方块,核心外环绕调度光圈与等待队列光点,车道上有小任务方块流动
四面八方的任务车道汇入一个反应堆核心,由运行时统一调度——这就是 tokio 的工作方式。

async/await:写起来像同步

虽然底层是事件驱动,但写出来的代码观感和同步几乎一样:在 async 函数里用 .await 等待一个异步操作,编译器帮你把它拆成"挂起—恢复"的状态机。你不需要手动写回调嵌套。

async fn fetch_double() -> u32 {
    let a = fetch_one().await;   // 等待,不阻塞线程
    let b = fetch_one().await;
    a + b
}

注意 .await 只能在 async 函数里用——这就是为什么 main 通常也得是 async,或交给运行时宏去包一层。它和 C# 的 async/await 体验很像,但 Rust 把运行时选择权留给了你。

tokio 运行时:谁来驱动 Future

既然 Future 不会自己跑,就得有个运行时来不断"戳"它、看它就绪了没。tokio 是 Rust 生态里事实标准的异步运行时,一行宏就帮你建好运行时并跑起来。

#[tokio::main]
async fn main() {
    let result = hello().await;
    println!("{}", result);
}

async fn hello() -> u32 { 42 }

#[tokio::main] 这个宏背后做了不少事:建好线程池、启动事件循环、把你的 main 当成第一个 Future 去驱动。你只管用 async/await 写逻辑,运行时负责调度。

spawn:把任务丢进运行时

顺序 await 是一个接一个跑。想让多个 Future 同时跑,就用 tokio::spawn 把任务丢进运行时,它立刻返回一个 JoinHandle,你可以之后再去拿结果。

#[tokio::main]
async fn main() {
    let h1 = tokio::spawn(work(1));
    let h2 = tokio::spawn(work(2));

    let r1 = h1.await.unwrap();   // 等第一个完成
    let r2 = h2.await.unwrap();   // 等第二个完成
    println!("{} {}", r1, r2);
}

async fn work(id: u32) -> u32 { id * 10 }

spawn 出去的两个任务在运行时里并发推进,而不是一个跑完再跑下一个。这就是"在不增加 OS 线程的前提下并发成百上千任务"的关键——这也是 Rust 做高并发网络服务的底气。

通道:在任务间传数据

多个任务并发跑起来后,它们之间怎么传数据?答案是异步通道 tokio::sync::mpsc:一个任务发送、另一个接收,发送与接收都是 await 的,不会阻塞线程。

use tokio::sync::mpsc;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(16);

    tokio::spawn(async move {
        tx.send("hello").await.unwrap();
    });

    let msg = rx.recv().await;   // 等收
    println!("{:?}", msg);       // Some("hello")
}

通道是异步任务之间通信的安全管道:发送端和接收端通过类型系统保证不会乱传,也避免了共享可变状态带来的数据竞争——这与所有权那一套思路是一脉相承的。

别在 async 里阻塞:选择正确的等待

异步运行时靠少数几个线程轮着驱动大量任务。如果你在 async 代码里做了一个真正会卡住线程的操作(比如同步的 sleep、阻塞的文件读),那这个线程就被你独占了,本该并发推进的其他任务全得等。

  • 异步代码里要睡,用 tokio::time::sleep 并 await,而不是标准库的同步 sleep。
  • 要做重计算或不得不阻塞时,把它丢到专门的阻塞线程池(如 spawn_blocking),别占用异步 worker。
  • 原则:异步上下文里只做"会让出去"的 await,不做"占着线程不放"的阻塞。

这是 Rust 异步最常见的性能陷阱——代码能跑、却因为一处阻塞把并发度全毁了。