外观
第 16 章 小鸟同时叼虫子
小螃蟹说
第 15 章,我们派了一队蚂蚁同时干活(线程)。这一章,换个思路:只派一只小鸟,但让它"同时"等三件事!
听起来矛盾?其实不。想一个生活场景:你在厨房烧水(要 3 分钟),这时候你不会干瞪眼看着水壶——你会顺便去浇花(2 分钟)、擦桌子(1 分钟)。三件事都做完了,水也刚好开了。
这就是异步(async,读作"诶身克"):一个人,等一件事的时候,顺便干另一件。 线程是"多请几个人";异步是"一个人会分身"。现代程序(网页服务器、下载器、聊天软件)几乎全靠异步——因为大多数时候,程序不是在"计算",而是在"等待"(等网络、等文件、等用户)。
我们今天的任务:三只小鸟同时叼虫子。每只鸟叼虫子要花不同的时间(2 秒、1 秒、3 秒)。关键问题:三只鸟一起叼,总共要多久? 6 秒(依次来)还是 3 秒(同时来)?答案会让你的程序快一倍——这就是异步的威力。
16.1 预览:我们要做什么
- 写三个
async函数(三只小鸟的任务单) - 用
join!让它们同时开工 - 收虫子,计时,见证"3 秒完成"
运行结果
text
小麻雀 出发叼虫子,预计 2 秒……
小燕子 出发叼虫子,预计 1 秒……
小黄鹂 出发叼虫子,预计 3 秒……
小燕子 叼到虫子了!
小麻雀 叼到虫子了!
小黄鹂 叼到虫子了!
三只小鸟一共花了 3 秒,叼到 12 条虫子!注意看:三只鸟同时出发,然后依次叼到(1 秒的小燕子先回来,3 秒的小黄鹂最后),总时间 3 秒——正好等于最慢的那只,而不是三只相加的 6 秒!
这一章你会学到:
| 知识 | 是什么 | 会用在哪儿 |
|---|---|---|
async fn | 会"分身"的函数 | 三只小鸟的任务单 |
.await | "我在这里等,等的时候去干别的" | 等叼虫子的地方 |
Future | 尚未完成的答卷 | async 函数的返回值 |
join! | 同时等好几个 Future | 三只小鸟一起等 |
| tokio | 帮 async 干活的"运行时" | 异步的发动机 |
16.2 动手做
步骤一:认识异步
先建项目,请帮手:
bash
cargo new bird_brigade
cd bird_brigade这一章需要 tokio——Rust 最流行的异步运行时(runtime)。"运行时"是什么?就是"帮 async 干活的人"(马上细讲)。在 Cargo.toml 里加:
toml
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }features 是"挑功能":我们只要 tokio 的三个功能(多线程运行时、宏、时间)。
异步 vs 同步,一句话: 同步是"排队做":烧水,干等 3 分钟,再浇花;异步是"分身做":烧水,等水开的时候去浇花。程序世界也一样:读文件、等网络,都是"烧水"——大部分时间在等。异步让等待的时间不再浪费。
步骤二:第一只小鸟
写第一只小鸟。把 src/main.rs 替换成:
rust
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
println!("小鸟出发叼虫子……");
sleep(Duration::from_secs(2)).await;
println!("叼到虫子了!");
}运行(会等 2 秒):
运行结果
text
小鸟出发叼虫子……
(等待 2 秒……)
叼到虫子了!两个新东西:
#[tokio::main]:属性标签(第 10 章见过 #[test])。它把普通 main 变成"tokio 的世界入口"——在这里面才能用 async 的花样。async 需要一个"发动机"(运行时),这个标签就是点火开关。
async fn main():async 关键字让函数变成异步函数。而 sleep(...).await 是"睡 2 秒"——注意 .await:main 在这里等,等的时候把控制权交出去,让别人(其他小鸟)干活。
步骤三:三只小鸟,顺序叼(6 秒)
现在写真正的"叼虫子"函数。把 main.rs 替换成:
rust
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
let start = std::time::Instant::now();
let worms1 = catch_worm("小麻雀", 2).await;
let worms2 = catch_worm("小燕子", 1).await;
let worms3 = catch_worm("小黄鹂", 3).await;
println!(
"三只小鸟顺序叼虫子,一共花了 {} 秒,叼到 {} 条虫子!",
start.elapsed().as_secs(),
worms1 + worms2 + worms3
);
}
async fn catch_worm(name: &str, seconds: u64) -> u32 {
println!("{} 出发叼虫子,预计 {} 秒……", name, seconds);
sleep(Duration::from_secs(seconds)).await;
println!("{} 叼到虫子了!", name);
seconds as u32 * 2
}运行(会等 6 秒):
运行结果
text
小麻雀 出发叼虫子,预计 2 秒……
小麻雀 叼到虫子了!
小燕子 出发叼虫子,预计 1 秒……
小燕子 叼到虫子了!
小黄鹂 出发叼虫子,预计 3 秒……
小黄鹂 叼到虫子了!
三只小鸟顺序叼虫子,一共花了 6 秒,叼到 12 条虫子!6 秒! 小麻雀先飞(2 秒),它叼完了小燕子才出发(1 秒),然后小黄鹂(3 秒)——一只接一只,排队叼。 因为我们在 main 里一个个 .await:await 第一个,等它完成,才轮到下一个。async 函数在这里被当成了普通函数用——异步的能力,一点没发挥。
看输出:每只鸟都是"出发 → 叼到",整整齐齐,和同步一模一样。问题很明显:大部分时间,其它鸟都在干等着。 怎么让它们同时飞?下一步。
步骤四:join!:同时叼(3 秒)
要让三只鸟同时飞,得先"准备好三张任务单",再一起等。把 main 里的三行改成:
rust
let bird1 = catch_worm("小麻雀", 2);
let bird2 = catch_worm("小燕子", 1);
let bird3 = catch_worm("小黄鹂", 3);
let (worms1, worms2, worms3) = tokio::join!(bird1, bird2, bird3);运行(会等 3 秒):
运行结果
text
小麻雀 出发叼虫子,预计 2 秒……
小燕子 出发叼虫子,预计 1 秒……
小黄鹂 出发叼虫子,预计 3 秒……
小燕子 叼到虫子了!
小麻雀 叼到虫子了!
小黄鹂 叼到虫子了!
三只小鸟一共花了 3 秒,叼到 12 条虫子!3 秒!时间砍半! 发生了什么?
关键区别在有没有 .await:
rust
let bird1 = catch_worm("小麻雀", 2); // 没有 await:只是"准备好任务单"
let bird1 = catch_worm("小麻雀", 2).await; // 有 await:立刻开等,等完才走下一行async fn 被调用时,并不会开始干活——它只是交回一张"任务单"。真正的名字叫 Future(未来值):"这件事将来会完成,现在还没做"。三张任务单(三个 Future)摆好,然后 tokio::join!(bird1, bird2, bird3) 把三张单子同时开跑——小鸟们同时出发,谁先叼完谁先报。
没有 await 的任务单
async fn 是"懒"的(第 11 章迭代器也懒):不 await,它就不动。像买了票还没进游乐场——票(任务单)在手里,项目(工作)还没玩。join! 就是"三张票一起进场"。
步骤五:收虫子
join! 不只同时等,还能同时收结果:(worms1, worms2, worms3) 把三只鸟叼到的虫子数(返回值)一次收齐(元组,第 7 章)。虫子数是 seconds as u32 * 2(第 9 章 as 变身):小麻雀 2 秒叼 4 条,小燕子 1 秒叼 2 条,小黄鹂 3 秒叼 6 条,一共 12 条。
最后把统计打印加全:
rust
println!();
println!(
"三只小鸟一共花了 {} 秒,叼到 {} 条虫子!",
start.elapsed().as_secs(),
worms1 + worms2 + worms3
);start.elapsed() 是"从 start 到现在过了多久"(第 9 章 {:.1} 那次的秒表,Instant::now() 是"现在此刻"),.as_secs() 取整数秒。
完整跑一遍,见证 3 秒奇迹:
运行结果
text
小麻雀 出发叼虫子,预计 2 秒……
小燕子 出发叼虫子,预计 1 秒……
小黄鹂 出发叼虫子,预计 3 秒……
小燕子 叼到虫子了!
小麻雀 叼到虫子了!
小黄鹂 叼到虫子了!
三只小鸟一共花了 3 秒,叼到 12 条虫子!同样的三只鸟、同样的虫子,顺序等要 6 秒,同时等只要 3 秒——快了一倍,代码只改了一点点。 这就是异步的魔法:把"等待"变成"顺便"。
16.3 知识深挖
16.3.1 Future:尚未完成的答卷
async fn 调用的返回值,类型叫 Future(未来值)——"这件事将来会完成的答卷"。
rust
async fn catch_worm(...) -> u32 { ... }
let future = catch_worm("小麻雀", 2); // Future:还没动工
future.await; // 等答卷:开等,等的时候让出控制权Future 是个 trait(第 9 章能力证书):"会在某刻变得就绪"。它的工作方式是轮询(poll):运行时时不时来问"你好了吗?"——没就绪,就去问别的 Future;就绪了,就把结果交出来。
.await 是"等答卷"的动作。注意它是让出控制权的等:当前 Future 在等,别的 Future 就趁机干活。这就是"一个人分身"的真相:不是真的同时跑,而是谁就绪谁干活,等待的时间不浪费。
16.3.2 运行时:谁在帮 async 干活?
async 只是"任务单",谁负责"盯着任务单、催进度"?答案是运行时(runtime)——tokio。
rust
#[tokio::main]
async fn main() { ... }这个属性标签做的事情是:启动一个 tokio 运行时(发动机),把你的 async main 丢进去跑,跑完关掉发动机。没有运行时,async 代码跑不起来——就像没有水,再好的水壶也烧不开。
tokio 是 Rust 最流行的运行时,它的功能可不止 sleep:
| tokio 功能 | 干什么 |
|---|---|
tokio::time::sleep | 异步等待(我们今天用的) |
tokio::net::TcpListener | 异步网络(第 20 章小网站的帮手!) |
tokio::spawn | 把任务单丢给别的线程跑 |
tokio::select! | 等最快的那个任务 |
一句话:async 是"怎么等",运行时是"谁来等"。 Rust 的标准库没有自带运行时(为了保持轻量),要自己挑一个——tokio 是绝大多数人的选择。
16.3.3 并发 vs 并行
两个词容易混,一次说清:
| 并行(parallel) | 并发(concurrent) | |
|---|---|---|
| 第 15 章的线程 | 是(多只蚂蚁真的一起跑) | 也是 |
| 这一章的 async | 不是(一只小鸟分身) | 是 |
| 比喻 | 两个厨师同时炒菜 | 一个厨师炒菜时顺便烧水 |
| 硬件 | 需要多个"核心" | 一个核心就够 |
并行 = 真的同时(多核 CPU 各干各的);并发 = 交错进行(一件事等着时干另一件)。异步是"单核也能并发"的艺术——大部分程序等的比算的多,所以异步往往比多线程更高效。
它们能合作
线程 + 异步不冲突:#[tokio::main] 默认就是多线程运行时——tokio 会在多个线程之间分配 Future。小网站(第 20 章)就是"多线程 + 异步"的合体。
16.3.4 join!:同时等全部
tokio::join! 是"同时等好几张答卷":
rust
let (a, b, c) = tokio::join!(future_a, future_b, future_c);- 同时开跑:三个 Future 一起推进,谁就绪谁先完成
- 一次收齐:结果装进元组,三个一起给
- 总时间 = 最慢的那个(不是总和!)
它还有个亲戚 tokio::select!:join 等全部,select 等最快的那个(比如"5 秒内没消息就超时")。以后写网络程序,这两个是常客。
16.3.5 async 的真实世界
我们的虫子是"睡几秒",真实世界的 async 等的是网络:
- 网页服务器:同时处理几千个连接,每个连接"等着"数据——几千个 Future 同时等,一台机器就够了
- 下载器:同时下载好几个文件,每个都在等网络——等的时候不占 CPU
- 聊天软件:一边等新消息,一边发消息——
select!同时等"收到"和"发送"
这一章的技能,第 20 章的小网站会原封不动地用上。异步,是现代 Rust 的心脏。
16.4 动脑筋练习
练习一:顺序版实验(6 秒 vs 3 秒)
把 main 改回"一个个 await"的顺序版,计时对比:
rust
let worms1 = catch_worm("小麻雀", 2).await;
let worms2 = catch_worm("小燕子", 1).await;
let worms3 = catch_worm("小黄鹂", 3).await;跑一次,看看花了多久,再对比 3 秒的 join! 版。亲眼看见"等待被浪费"的样子,才懂异步的价值。
点开看答案
顺序版运行:
text
小麻雀 出发叼虫子,预计 2 秒……
小麻雀 叼到虫子了!
小燕子 出发叼虫子,预计 1 秒……
小燕子 叼到虫子了!
小黄鹂 出发叼虫子,预计 3 秒……
小黄鹂 叼到虫子了!
三只小鸟顺序叼虫子,一共花了 6 秒,叼到 12 条虫子!6 秒——小麻雀等 2 秒时,小燕子干等;小燕子 1 秒时,小黄鹂干等……总共 6 秒,但每只鸟真正干活只要自己那几秒,其余全在等。
对比 join! 版的 3 秒:同样三只鸟,同样 12 条虫子,join! 让等待重叠,时间减半。 看输出顺序的差别:顺序版是"出发→叼到→出发→叼到",排队;join! 版是"出发、出发、出发→叼到、叼到、叼到",齐飞。一行 join!,天壤之别。
练习二:第四只小鸟
加一只"小翠鸟",叼虫子要 4 秒。改动:任务单、join! 的元组、打印的虫子数。跑一跑——总时间变成几秒?
点开看答案
四处改动:
rust
let bird1 = catch_worm("小麻雀", 2);
let bird2 = catch_worm("小燕子", 1);
let bird3 = catch_worm("小黄鹂", 3);
let bird4 = catch_worm("小翠鸟", 4);
let (worms1, worms2, worms3, worms4) = tokio::join!(bird1, bird2, bird3, bird4);打印处:
rust
worms1 + worms2 + worms3 + worms4运行:
text
四只小鸟一共花了 4 秒,叼到 20 条虫子!4 秒,不是 2+1+3+4=10 秒! 四只鸟同时飞,总时间 = 最慢的小翠鸟(4 秒)。虫子 4+2+6+8=20 条。加一只鸟,时间几乎没变——这就是并发最诱人的地方:任务越多,越划算。
练习三:谁叼得多,谁说了算
现在虫子数是"秒数 × 2",由速度决定。改成每个调用者自己指定叼几条:catch_worm("小麻雀", 2, 3) 表示"小麻雀花 2 秒,叼 3 条"。想想函数签名怎么改。
点开看答案
签名加一个参数,不再用 seconds * 2:
rust
async fn catch_worm(name: &str, seconds: u64, worms: u32) -> u32 {
println!("{} 出发叼虫子,预计 {} 秒……", name, seconds);
sleep(Duration::from_secs(seconds)).await;
println!("{} 叼到 {} 条虫子!", name, worms);
worms
}调用处:
rust
let bird1 = catch_worm("小麻雀", 2, 3);
let bird2 = catch_worm("小燕子", 1, 5);
let bird3 = catch_worm("小黄鹂", 3, 1);
let (worms1, worms2, worms3) = tokio::join!(bird1, bird2, bird3);运行:
text
三只小鸟一共花了 3 秒,叼到 9 条虫子!3 + 5 + 1 = 9 条,时间还是 3 秒。async 函数和普通函数一样,参数随便设计——异步只是"等待的方式"变了,函数本身还是函数。
16.5 完整代码清单
项目结构:
text
bird_brigade/
├── Cargo.toml
└── src/
└── main.rs文件:Cargo.toml
toml
[package]
name = "bird_brigade"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }文件:src/main.rs
rust
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
let start = std::time::Instant::now();
let bird1 = catch_worm("小麻雀", 2);
let bird2 = catch_worm("小燕子", 1);
let bird3 = catch_worm("小黄鹂", 3);
let (worms1, worms2, worms3) = tokio::join!(bird1, bird2, bird3);
println!();
println!(
"三只小鸟一共花了 {} 秒,叼到 {} 条虫子!",
start.elapsed().as_secs(),
worms1 + worms2 + worms3
);
}
async fn catch_worm(name: &str, seconds: u64) -> u32 {
println!("{} 出发叼虫子,预计 {} 秒……", name, seconds);
sleep(Duration::from_secs(seconds)).await;
println!("{} 叼到虫子了!", name);
seconds as u32 * 2
}怎么运行:
bash
cd bird_brigade
cargo run(第一次运行要下载编译 tokio,等一会儿是正常的。)
运行检查单:
三只鸟同时出发,依次叼到,总时间约 3 秒
多跑几次,叼到的顺序可能不同(小燕子几乎总是第一)
虫子总数 = 12(4 + 2 + 6)
把 join! 换成逐个 .await,时间变成 6 秒(看完改回来!)
删掉 #[tokio::main],看编译器报什么错(async 没有发动机)
给 catch_worm 加一个参数(练习三),功能照常