13.1 这节课解决什么问题
第 12 课的线程模型有个物理瓶颈:线程是操作系统资源 ------每个线程要一块独立栈(默认几 MB),上下文切换有内核开销。1 万个并发连接开 1 万线程,系统直接顶不住。而网络/LLM/文件这类场景,绝大多数时间线程只是等(等网络包、等响应),白白占着资源。
异步(async)的答案是用户态任务:
arduino
线程(thread) → 操作系统调度,1:1 内核线程,栈大、切换贵
异步任务(future) → 运行时(tokio)自己调度,一个线程上跑成千上万个任务
"等 IO"时任务挂起让出线程,事件就绪了再被唤醒继续跑
在 Rust 里 async fn 返回一个 Future------一个"可被驱动到完成的惰性状态机"。它自己不执行,必须由执行器/运行时(tokio)去 poll 它。本课目标:能用 tokio 写出并发 IO 程序,并理解 async 的"必须知道"的规矩,为 17-19 课的 LLM 流式问答与 SSE 打好地基。
rust
关键心智:async 不改变并发本质,只改变"并发单元"------由线程变成任务。
任务在线程间可迁移,所以 spawn 出去的任务要求 Send + 'static(和线程同款规矩)。
💡 为什么本课程选 tokio?AI/HTTP/SSE 生态事实标准(reqwest、axum 都基于 tokio);官方 async book 也用它示范。先精通 tokio 一个运行时,胜过对十个运行时一知半解。
13.2 Future 的直觉:一个可等待的状态机
rust
// 一个"async 函数"调用时不执行,返回 Future
async fn fetch_data() -> String {
// 假设这里做了耗时 IO......
String::from("数据")
}
// 等价于展开成状态机:
// 第 0 次 poll:开始执行,遇到 await 没就绪 → 返回 Pending,存下状态
// 数据就绪后再次 poll:从上次挂起点继续 → 最终 Ready(value)
三个必须建立的直觉:
async fn只有被 poll 才会真正干活------和迭代器 lazy 一个道理(第 9 课);.await是"让出点" :执行到future.await时,若还没就绪,任务挂起,线程去跑别的任务;- 必须有个执行器一直 poll 这些 future------tokio 就是那个执行器。
rust
// 不经过运行时,future 不会推进:
async fn hello() -> String { String::from("world") }
fn main() {
let fut = hello(); // 只是构建 future,什么都没发生
// println!("{}", fut.await); // ❌ .await 只能用在 async 上下文里
// 必须靠运行时驱动(见 13.3 的 #[tokio::main])
}
13.3 tokio 起步:运行时与 #tokio::main
toml
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] }
rust
#[tokio::main] // 宏:把 main 包进多线程运行时
async fn main() {
println!("hello from async");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
println!("等了 100ms");
}
#[tokio::main] 背后的等价物(理解它,调试时才有直觉):
rust
fn main() {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(4) // 默认=CPU 核数
.enable_all() // 打开 IO/时间驱动
.build()
.unwrap()
.block_on(async {
println!("手工搭运行时也行");
});
}
两个常用宏一并介绍:
rust
#[tokio::main]
async fn main() {
// join!: 并发跑多个 future,全部完成才继续(类似 Promise.all)
let (a, b) = tokio::join!(compute_a(), compute_b());
// try_join!: 任一失败即整体失败(要求各 future 返回 Result)
// let (x, y) = tokio::try_join!(fallible_x(), fallible_y())?;
println!("{a} {b}");
}
async fn compute_a() -> String { String::from("A") }
async fn compute_b() -> String { String::from("B") }
13.4 并发执行任务:tokio::spawn
把一个 future 脱离当前执行流程 地丢给运行时并发跑,用 spawn:
rust
use std::time::Duration;
#[tokio::main]
async fn main() {
let handle1 = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(50)).await;
println!("任务1 完成");
10
});
let handle2 = tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(20)).await;
println!("任务2 完成");
20
});
// await JoinHandle 取回任务返回值(Result,任务 panic 会 Err)
let r1 = handle1.await.unwrap();
let r2 = handle2.await.unwrap();
println!("{r1} + {r2} = {}", r1 + r2);
}
spawn 的约束与线程相同------闭包必须 Send + 'static(可能被迁移到别的 worker 线程,不能借用局部数据):
rust
#[tokio::main]
async fn main() {
let data: &'static str = "常量没问题"; // 'static 数据可被借用
let owned = String::from("拥有的数据"); // 推荐:move 进去
tokio::spawn(async move {
println!("{data} {owned}");
})
.await
.unwrap();
}
⚠️
tokio::spawn是立即并发 ;而async{}.await是顺序等待。区别(重要):
rustasync fn slow(tag: &str) { tokio::time::sleep(Duration::from_millis(100)).await; println!("{tag}"); } #[tokio::main] async fn main() { // 顺序版:先等 100ms 的 a,再等 100ms 的 b → 共 ~200ms slow("a").await; slow("b").await; // 并发版:spawn 两个任务,再一起等 → 共 ~100ms let ha = tokio::spawn(slow("c")); let hb = tokio::spawn(slow("d")); ha.await.unwrap(); hb.await.unwrap(); }
13.5 async 里的 channel:tokio::sync
线程版用 std::sync::mpsc,异步版用 tokio::sync::mpsc(.await 式收发,不阻塞线程):
rust
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel(32); // 容量 32 的有界通道
let producer = tokio::spawn(async move {
for i in 0..5 {
if tx.send(i).await.is_err() { // 接收端已关闭 → 停止发送
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
});
let consumer = tokio::spawn(async move {
while let Some(n) = rx.recv().await { // None = 所有发送端关闭且队列清空
println!("收到 {n}");
}
});
producer.await.unwrap();
consumer.await.unwrap();
}
常用变体:watch(一发多收的"最新值广播",做配置热更新)、broadcast(多收方都拿到全量消息)、oneshot(一次性的"应答"通道,请求-响应很常用------13.9 练习会用到)。
13.6 超时、取消与 select
13.6.1 timeout:给 await 加"最长等待"
rust
use std::time::Duration;
#[tokio::main]
async fn main() {
let slow = async {
tokio::time::sleep(Duration::from_secs(5)).await;
"完成"
};
match tokio::time::timeout(Duration::from_millis(100), slow).await {
Ok(result) => println!("及时返回: {result}"),
Err(_elapsed) => println!("超时了!future 已被丢弃"),
}
}
timeout 超时会**取消(drop)**内部的 future------这就是"可取消性":async 任务被丢弃即停止,没有僵尸线程。
13.6.2 select!:等待多个事件,谁先来处理谁
rust
use tokio::sync::mpsc;
use std::time::Duration;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<String>(8);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(200)).await;
tx.send(String::from("延迟消息")).await.ok();
});
// select!: 同时等"收消息"与"倒计时",先就绪的执行,另一个被取消(继续等下一次循环)
loop {
tokio::select! {
Some(msg) = rx.recv() => {
println!("通道来了: {msg}");
break;
}
_ = tokio::time::sleep(Duration::from_millis(50)) => {
println!("还没消息,先干点别的......");
}
}
}
}
💡
select!内部会自动公平轮询各分支(默认随机顺序,可用biased;改为按书写顺序)。它是实现"带心跳的请求"、"超时降级"、"优雅关闭信号"的瑞士军刀------17 课的 SSE 阅读循环会用到它的实际场景。
13.7 ⚠️ 异步的两大红线
红线一:async 里别做"阻塞式"调用
rust
#[tokio::main]
async fn main() {
// ❌ 会阻塞整个 worker 线程:async 世界里没有"等一会再让出"
// std::thread::sleep(Duration::from_millis(100));
// ❌ 大 CPU 密集 / 同步 IO(read 文件阻塞)也应避免直接 .await 里跑
// ✅ 用 tokio::time::sleep(挂起而非阻塞)
tokio::time::sleep(Duration::from_millis(100)).await;
// ✅ 确实要做同步重活 → 丢给阻塞线程池
let heavy = tokio::task::spawn_blocking(|| {
// 这里可以放心用 std::thread::sleep / 同步文件 IO / CPU 密集计算
let mut sum = 0u64;
for i in 0..10_000_000u64 { sum += i; }
sum
});
let result = heavy.await.unwrap();
println!("重活结果: {result}");
}
为什么 :tokio 的 worker 线程数是有限的(默认 = CPU 核数)。一个任务把线程"睡"住或算满,其他任务全卡在那条线程上。判断口诀:遇到 std::thread::sleep / 同步 read_to_string / 大循环 → 换 tokio::time::sleep 或 spawn_blocking。
红线二:spawn 的任务不能借用非 'static 数据
需要共享状态时(async 世界里也要 Arc):
rust
use std::sync::Arc;
#[tokio::main]
async fn main() {
let shared = Arc::new(42);
for _ in 0..3 {
let s = Arc::clone(&shared);
tokio::spawn(async move {
println!("task sees {s}");
});
}
}
💡 异步任务之间若要共享可变状态 :首选把状态收进单一 actor(任务持有状态 + 收消息),其次才考虑
Arc<tokio::sync::Mutex<T>>。tokio 的 Mutex 与 std 的 Mutex 都可用,但不要 在.await挂起期间长时间持有std::sync::Mutexguard(会阻塞线程);tokio::sync::Mutex 则允许跨 await。
13.8 组合示范:并发拉取 + 超时 + 汇总(常见范式)
把本课知识点缝成一个"并发请求并带超时"的最小骨架(reqwest 第 14 课系统登场,这里只做任务编排演示):
rust
use std::time::Duration;
// 模拟一个 80ms 的"网络请求"
async fn fake_fetch(url: &'static str) -> String {
tokio::time::sleep(Duration::from_millis(80)).await;
format!("{url} 的内容")
}
#[tokio::main]
async fn main() {
let urls = ["https://a.example.com", "https://b.example.com", "https://c.example.com"];
// 并发发起所有请求(spawn 后统一 await,天然并发)
let tasks: Vec<_> = urls
.iter()
.map(|u| tokio::spawn(async move { fake_fetch(u).await }))
.collect();
// 逐个带超时地收结果,失败/超时的标为降级信息
let mut results = Vec::new();
for t in tasks {
let r = match tokio::time::timeout(Duration::from_millis(200), t).await {
Ok(Ok(ok)) => ok,
Ok(Err(_)) => String::from("<任务内部出错>"),
Err(_) => String::from("<超时>"),
};
results.push(r);
}
println!("{results:#?}");
}
13.9 📝 动手练习
参考实现放 code/13-async/(写作时同步给出),本课工程需 Cargo.toml 加 tokio 依赖。
- 运行时验证 :写
async fn里分别用#[tokio::main]与手工block_on两种方式运行,体会"async fn 不跑"到底是什么意思。 - 并发 vs 顺序 :两个各睡 200ms 的 async fn,先顺序 await 测耗时(应 ~400ms),再
tokio::spawn+ join 测耗时(应 ~200ms),贴出两次Instant计时结果。 - channel 流水线 :生产 1..=100 经 mpsc 发给消费者,消费者求和返回,验证 5050;再用
select!在消费的同时每 100ms 打一次"心跳"。 - timeout 降级 :mock 一个"永远 pending"的 future(
futures::future::pending()或死循环 sleep),用timeout(100ms)保证 100ms 后能正常返回降级结果------这是"外部 LLM 不响应"演练。 - 超时与取消:观察 timeout 触发后,future 内的循环是否还打印(体会 drop 取消语义)。
- oneshot 应答 :
let (tx, rx) = tokio::sync::oneshot::channel(),spawn 一个任务算1_000_000内质数个数后tx.send,主任务rx.await取结果。 - spawn_blocking :把
1..=1_000_000u64求和(纯 CPU)放spawn_blocking里跑,对比在主 async 里直接跑,体会对 worker 线程的占用(可同时开一个每秒打印的心跳任务观察卡顿差异)。 - 综合(重点) :实现"并发下载 3 个 mock 数据 + 任一超时 150ms 即放弃整体":
select!同时等 3 个任务的 JoinHandle 或一个统一 timeout;用biased;观察分支优先级差异并注释说明。
验收门禁 :能解释 async fn 返回 Future 且需要执行器;spawn 与直接 .await 的并发差异;.await 挂起与 std::thread::sleep 阻塞的区别;timeout 丢弃 future = 取消;async 任务要求 Send + 'static 的原因。
✅ 本节小结
- Future 模型 :惰性状态机,执行器 poll 驱动,
.await是让出点; - tokio :
#[tokio::main]建立多线程运行时;join!/try_join!聚合并发 future; - spawn :任务脱离主流程并发跑,返回 JoinHandle;约束
Send + 'static; - 同步原语 :
tokio::sync的 mpsc/watch/broadcast/oneshot 都基于 await 而非阻塞; - 编排 :
timeout(超时=取消 future)、select!(多路复用)、spawn_blocking(把阻塞活丢出 worker 线程); - 红线:async 上下文里不 sleep/不阻塞(换 tokio::time / spawn_blocking);共享状态用 Arc 收进任务;
- 认知:async 是"更省的并发单元",线程与 async 各司其职、常混合使用。
下一课预告 :第 14 课《网络请求与 JSON:reqwest + serde》------把 async 用起来:发 HTTP 请求、把 JSON 反序列化成领域类型、处理错误与超时。serde 的 derive 流程你已在 6-8 课反复铺垫,这课正式授衔。13 课 + 14 课 = 15 课 SSE 流式读取的所有前置。