跳转到内容

Rust async/await

想象一家餐厅。在并发那一页你学了线程,那就像每张桌子雇一个服务员。这样能行,但服务员很贵,而且他们大部分时间都只是站在那儿等厨房出菜。

异步(async)就像一个服务员同时照看很多桌。服务员先给 1 号桌点单,把单子交给厨房,菜还在做的时候,就去 2 号桌。没有人闲站着。这就是 Rust 里 async 和 .await 背后的想法:一个任务在等待(等网络、等文件、等计时器)的时候,另一个任务就可以运行。

当程序有大量时间花在等待上时,异步最能发挥作用:Web 服务器、API 客户端、聊天应用、机器人。这一页使用 tokio,它是 Rust 最流行的异步运行时。

Rust 标准库提供了 async 和 .await 关键字,但没有提供真正运行异步代码的“引擎”。这个引擎叫做运行时(runtime),大多数项目用的都是 tokio。在一个新项目里加上它:

Terminal window
cargo new async_demo
cd async_demo
cargo add tokio --features full

这会在你的 Cargo.toml 里加上这一行:

[dependencies]
tokio = { version = "1", features = ["full"] }

full 这个 feature 会打开所有功能(计时器、任务、网络等等)。学习阶段这是最省事的选择。这一页上的“动手试一试”按钮也能用,因为 Rust Playground 内置了 tokio。

use std::time::Duration;
async fn make_coffee() -> String {
tokio::time::sleep(Duration::from_millis(20)).await;
String::from("咖啡")
}
#[tokio::main]
async fn main() {
println!("正在点单……");
let drink = make_coffee().await;
println!("你的{}好了", drink);
}

输出:

正在点单……
你的咖啡好了
▶ 动手试一试

这里有三样新东西:

  • async fn 定义一个可以在中途暂停、之后再继续的函数。
  • .await 的意思是“等这个完成,而且在等的时候,让别的任务先运行”。.await 只能在 async 函数里使用。
  • #[tokio::main] 启动 tokio 运行时,并在上面运行你的 async fn main。没有它,main 就不能是 async 的。

tokio::time::sleep 是“等一会儿”的异步版本。它只暂停当前这个任务,而不是整个线程。

下面这一点会让大多数人吃惊。调用一个 async fn 并不会运行它。你得到的是一个 future:一个表示“以后才会做的工作”的值。在你 .await 它之前,什么都不会发生:

async fn say_hello() {
println!("来自 future 内部的问候!");
}
#[tokio::main]
async fn main() {
let future = say_hello(); // 现在还什么都没运行
println!("future 已创建,但还没开始");
future.await; // 现在才运行
println!("完成");
}

输出:

future 已创建,但还没开始
来自 future 内部的问候!
完成
▶ 动手试一试

可以把 future 想成一张写好的点菜单。写好单子并不会把菜做出来,把单子交给厨房(.await)才会。

到目前为止,我们一次只等一件事。真正的威力在于同时等好几件事。tokio::join! 会让几个 future 一起运行,并等到它们全部完成,然后把它们的结果作为一个元组交给你:

use std::time::Duration;
use tokio::time::sleep;
async fn fetch_user() -> String {
sleep(Duration::from_millis(30)).await; // 假装这是一次网络请求
String::from("小安")
}
async fn fetch_order_count() -> u32 {
sleep(Duration::from_millis(20)).await;
3
}
#[tokio::main]
async fn main() {
let (user, orders) = tokio::join!(fetch_user(), fetch_order_count());
println!("{}有 {} 个订单", user, orders);
}

输出:

小安有 3 个订单
▶ 动手试一试

如果一个接一个地 await,总共要等大约 30 + 20 = 50ms。用 join! 的话,两段等待同时进行,所以大约只要 30ms,也就是最慢那个所花的时间。换成真实的网络请求,这个差距会很快累积起来。

future 的数量固定时,join! 很好用。如果你有一整列工作,就用 tokio::spawn。它把一个 future 作为单独的任务(task)交给运行时,立刻开始运行,并返回一个 JoinHandle。之后你再 .await 这个句柄来拿结果:

use std::time::Duration;
use tokio::time::sleep;
async fn download(id: u64) -> String {
sleep(Duration::from_millis(50 - id * 10)).await;
format!("文件 {} 下载完成", id)
}
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for id in 1..=3 {
let handle = tokio::spawn(download(id)); // 现在就开始运行
handles.push(handle);
}
for handle in handles {
let result = handle.await.unwrap();
println!("{}", result);
}
}

输出:

文件 1 下载完成
文件 2 下载完成
文件 3 下载完成
▶ 动手试一试

仔细看:文件 3 的等待时间最短,所以它其实是最先完成的。但我们是按await 句柄的顺序打印结果的,所以输出永远是 1、2、3。三个下载仍然是同时进行的。

为什么要 .unwrap()?await 一个 JoinHandle 得到的是一个 Result,因为那个任务有可能 panic 了。如果真的 panic 了,你会得到一个 Err,而不是让整个程序崩溃。这和并发那一页的 thread::spawn 加 .join() 非常像,这是有意设计的。

忘了写 .await。 因为 future 是惰性的,忘了 .await 就意味着这项工作永远不会执行。如果你用到了这个值,编译器会拦住你:

async fn get_number() -> u32 {
5
}
#[tokio::main]
async fn main() {
let n = get_number(); // 忘了写 .await
println!("{}", n + 1);
}
error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>`
--> src/main.rs:8:22
|
8 | println!("{}", n + 1);
| - ^ - {integer}
| |
| impl Future<Output = u32>

这条信息说的是:n 是一个产出 u32 的 future,而不是一个 u32。修复方法是写成 let n = get_number().await;。如果函数什么都不返回,你只是调用了它,比如 save_file();,那就只会得到一个警告,所以要认真看警告:

warning: unused implementer of `Future` that must be used
|
7 | save_file();
| ^^^^^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them

在异步代码里用 std::thread::sleep。 这段代码能正常编译,所以特别隐蔽:

async fn slow_task() {
std::thread::sleep(std::time::Duration::from_secs(1)); // 会阻塞整个线程!
}

std::thread::sleep 会冻结整个线程。运行时没法切换到别的任务,于是这个线程上的所有任务都停了下来,就像一个服务员站在某张桌子旁睡着了。在异步代码里,一定要改用 tokio::time::sleep(...).await。其他又慢又会阻塞的工作也一样,比如用 std::fs 读取很大的文件:tokio 有对应的异步版本(tokio::fs),或者你也可以用 tokio::task::spawn_blocking。

以为异步能让 CPU 密集的工作变快。 异步在任务等待的时候才有帮助。任务很忙的时候,比如缩放图片或者大量计算,它就帮不上忙了。一个服务员接的单再多,也不能让菜做得更快。繁重的 CPU 工作请用线程(或者 rayon 这样的 crate)。

适合用异步的情况…… 适合用线程的情况……
大部分时间都花在等待上(网络、数据库、计时器) 工作是繁重的计算
需要同时运行成千上万个任务(比如 Web 服务器) 只需要几件事并行运行
你在用 reqwest 或 axum 这样的异步 crate 你不想多依赖一个运行时

不必只选一个。很多实际程序用异步处理网络,用线程处理繁重的工作。

需要让任务之间互相传消息?tokio 有自己的信道 tokio::sync::mpsc。它的用法和信道那一页讲的标准库信道一样,只是要写成 rx.recv().await,所以等消息时不会阻塞线程。

Rust 异步生态的大部分都建立在 tokio 之上。有两个 crate 你会到处看到:

  • reqwest(英文):调用 Web API 的 HTTP 客户端。写一句 reqwest::get(url).await 就能获取一个网页。
  • axum(英文):tokio 团队做的 Web 框架,用来构建服务器和 API。

两者都能和 serde_json 很好地配合,用来读写 JSON。

写三个异步函数:make_toast(等待 30ms)、fry_eggs(等待 20ms)和 brew_coffee(等待 10ms)。每个函数返回一个写着食物名字的 String。让三个函数同时运行,然后打印做好了什么。提示:tokio::join! 接收几个 future,返回一个装着它们结果的元组。

查看答案
use std::time::Duration;
use tokio::time::sleep;
async fn make_toast() -> String {
sleep(Duration::from_millis(30)).await;
String::from("吐司")
}
async fn fry_eggs() -> String {
sleep(Duration::from_millis(20)).await;
String::from("煎蛋")
}
async fn brew_coffee() -> String {
sleep(Duration::from_millis(10)).await;
String::from("咖啡")
}
#[tokio::main]
async fn main() {
let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee());
println!("早餐:{}、{}和{}", toast, eggs, coffee);
}

输出:

早餐:吐司、煎蛋和咖啡
▶ 动手试一试

咖啡最先做好,但元组里结果的顺序永远和你列出 future 的顺序一样。整顿早餐大约花 30ms(最慢那道菜的时间),而不是 60ms。

写一个 async fn square_later(n: u64) -> u64,它先等待 10 * n 毫秒,再返回 n * n。在 main 里,为 1 到 4 的每个数字各创建一个任务,然后 await 全部任务,打印总和。提示:把每个 tokio::spawn(...) 返回的句柄放进一个 Vec,再遍历这些句柄,把 handle.await.unwrap() 加起来。

查看答案
use std::time::Duration;
use tokio::time::sleep;
async fn square_later(n: u64) -> u64 {
sleep(Duration::from_millis(10 * n)).await;
n * n
}
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in 1..=4 {
handles.push(tokio::spawn(square_later(n)));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("总和 = {total}");
}

输出:

总和 = 30
▶ 动手试一试

四个任务都立刻开始,并且同时在等待,所以总共大约花 40ms,而不是 10 + 20 + 30 + 40 = 100ms。

  • 异步让一个线程可以处理很多任务:任务在等待时,线程就切换到别的任务上。
  • Rust 需要一个运行时来运行异步代码。用 cargo add tokio --features full 添加 tokio,并给 main 加上 #[tokio::main]。
  • async fn 返回一个 future。future 是惰性的:在你 .await 之前,什么都不会运行。
  • 用 tokio::join! 同时等待多个 future,用 tokio::spawn 启动任务并拿到一个 JoinHandle。
  • 千万不要在异步代码里用 std::thread::sleep,要用 tokio::time::sleep(...).await。
  • 异步是为等待准备的,不适合繁重的 CPU 工作。那种工作请用线程。

小测验

1. 调用一个 async fn 却不写 .await,会发生什么?

2. #[tokio::main] 的作用是什么?

3. 两个 future 分别等待 30ms 和 20ms。对它们使用 tokio::join! 大约要多久?

4. 为什么在异步代码里用 std::thread::sleep 会出问题?

5. 下面哪项工作最适合用异步?

得分: 0 / 5