Rust async/await
想象一家餐厅。在并发那一页你学了线程,那就像每张桌子雇一个服务员。这样能行,但服务员很贵,而且他们大部分时间都只是站在那儿等厨房出菜。
异步(async)就像一个服务员同时照看很多桌。服务员先给 1 号桌点单,把单子交给厨房,菜还在做的时候,就去 2 号桌。没有人闲站着。这就是 Rust 里 async 和 .await 背后的想法:一个任务在等待(等网络、等文件、等计时器)的时候,另一个任务就可以运行。
当程序有大量时间花在等待上时,异步最能发挥作用:Web 服务器、API 客户端、聊天应用、机器人。这一页使用 tokio,它是 Rust 最流行的异步运行时。
安装 tokio
Section titled “安装 tokio”Rust 标准库提供了 async 和 .await 关键字,但没有提供真正运行异步代码的“引擎”。这个引擎叫做运行时(runtime),大多数项目用的都是 tokio。在一个新项目里加上它:
cargo new async_democd async_democargo add tokio --features full这会在你的 Cargo.toml 里加上这一行:
[dependencies]tokio = { version = "1", features = ["full"] }full 这个 feature 会打开所有功能(计时器、任务、网络等等)。学习阶段这是最省事的选择。这一页上的“动手试一试”按钮也能用,因为 Rust Playground 内置了 tokio。
第一个异步程序
Section titled “第一个异步程序”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 是“等一会儿”的异步版本。它只暂停当前这个任务,而不是整个线程。
future 是惰性的
Section titled “future 是惰性的”下面这一点会让大多数人吃惊。调用一个 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)才会。
用 join! 同时运行多个任务
Section titled “用 join! 同时运行多个任务”到目前为止,我们一次只等一件事。真正的威力在于同时等好几件事。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,也就是最慢那个所花的时间。换成真实的网络请求,这个差距会很快累积起来。
用 tokio::spawn 创建任务
Section titled “用 tokio::spawn 创建任务”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)。
异步还是线程?
Section titled “异步还是线程?”| 适合用异步的情况…… | 适合用线程的情况…… |
|---|---|
| 大部分时间都花在等待上(网络、数据库、计时器) | 工作是繁重的计算 |
| 需要同时运行成千上万个任务(比如 Web 服务器) | 只需要几件事并行运行 |
| 你在用 reqwest 或 axum 这样的异步 crate | 你不想多依赖一个运行时 |
不必只选一个。很多实际程序用异步处理网络,用线程处理繁重的工作。
需要让任务之间互相传消息?tokio 有自己的信道 tokio::sync::mpsc。它的用法和信道那一页讲的标准库信道一样,只是要写成 rx.recv().await,所以等消息时不会阻塞线程。
大家用它做什么
Section titled “大家用它做什么”Rust 异步生态的大部分都建立在 tokio 之上。有两个 crate 你会到处看到:
- reqwest(英文):调用 Web API 的 HTTP 客户端。写一句
reqwest::get(url).await就能获取一个网页。 - axum(英文):tokio 团队做的 Web 框架,用来构建服务器和 API。
两者都能和 serde_json 很好地配合,用来读写 JSON。
1. 一次做好早餐
Section titled “1. 一次做好早餐”写三个异步函数: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。
2. 在后台计算平方
Section titled “2. 在后台计算平方”写一个 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
- 异步编程基础:Async、Await、Future 与 Stream — Rust 程序设计语言:官方讲解 future、
async、.await和 stream 的章节。 - tokio 教程(英文):一份动手实践的指南,带你做一个类似 Redis 的小服务器,涵盖任务、共享状态和信道。
- docs.rs 上的 tokio(英文):
tokio::spawn、tokio::time、tokio::sync等的 API 参考。 - Asynchronous Programming in Rust(async book)(英文):更深入地介绍 future 和执行器(executor)在底层是怎样工作的。