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 丢进运行时,让它在后台并发跑。
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 异步最常见的性能陷阱——代码能跑、却因为一处阻塞把并发度全毁了。
实战:并发跑任务并汇总
串起来:用 tokio 起运行时,spawn 出三个并发任务,再把它们的结果汇总求和。这是并发聚合的最小骨架。
#[tokio::main]
async fn main() {
let mut handles = vec![];
for i in 1..=3 {
handles.push(tokio::spawn(compute(i)));
}
let mut total = 0;
for h in handles {
total += h.await.unwrap();
}
println!("总和 = {}", total); // 10 + 20 + 30
}
async fn compute(i: u32) -> u32 { i * 10 }
三个 compute 并发跑,主线程在 JoinHandle 上依次等结果、累加。从这个骨架出发,把 compute 换成"发一个网络请求""读一个分片文件",就是真实的并发抓取与聚合程序。
Rust 异步检查清单
把 Rust 异步记成一条链:async 定义 Future → tokio 运行时驱动 → spawn 并发 → 通道传数据 → await 汇总。提交代码前对照自查:
- 写出来的 Future 是否真的交给运行时跑了,而不是定义完就扔在那?
- 需要并发的任务是否用 spawn 丢进运行时,而不是顺序 await?
- 任务之间传数据是否走了异步通道,而不是共享可变状态?
- async 上下文里有没有误用同步阻塞调用,卡住 worker 线程?
- 阻塞型的重活是不是放到了 spawn_blocking,而不是挤占异步线程?
Rust 异步的核心心智:Future 是惰性的、运行时负责驱动、worker 线程绝不能被阻塞。守住这三点,并发程序既快又不踩坑。
至此本批三篇从网络、C# 高级特性到 Rust 异步收束——它们其实在回答同一件事:怎么在现代语言里高效、安全地同时做多件事。