阶段五 · 进阶与陷阱

异步常见陷阱

一句话总结

异步 Rust 的陷阱大多源于一个根因:在不该阻塞的地方阻塞了运行时线程.
本篇汇总五个高频坑--阻塞调用,CPU 密集占用,持锁过 await,忘记 await,误以为相邻 await 并发--并给出每个的识别方法与正确写法.

一个根因串起大多数坑

回顾第 02,05 篇:异步运行时用少数几个 worker 线程承载海量任务,靠的是"任务一等待就让出线程".这个模型有一条铁律:

绝不要在异步任务里阻塞 worker 线程

一个 worker 线程可能正背着成百上千个任务.在某个任务里执行了阻塞调用或长时间纯计算,这个线程无法去 poll 其他任务,它身上所有任务集体卡死.

在 Go 里 runtime 会把阻塞的 goroutine 挪走,补充线程;Tokio 默认不会--这是 Go 程序员转 Rust 最致命的认知差.

陷阱一:在异步里调用阻塞 API

最常见,危害最大.在 async 里调用同步阻塞函数(std::thread::sleep,同步文件/网络库,阻塞的数据库驱动等):

async fn bad() {
std::thread::sleep(Duration::from_secs(1)); // 冻结整个 worker 线程!
let data = std::fs::read("big.txt").unwrap(); // 同步阻塞 IO,同样有害
}

正确做法分两类:

  • 异步替代品就用它:tokio::time::sleep().await,tokio::fs::read().await.
  • 没有异步版(如某个只提供同步接口的 CPU/阻塞库),用 spawn_blocking 把它挪到专门的阻塞线程池:
let result = tokio::task::spawn_blocking(|| {
// 运行在独立的阻塞线程池,不影响异步 worker
expensive_blocking_call()
}).await.unwrap();

Tokio 的异步 worker 线程像餐厅前厅的服务员,要灵活穿梭服务多桌;spawn_blocking 的线程池像后厨,专门处理耗时的体力活.
把阻塞任务丢进后厨,前厅才不会因为一个慢活而瘫痪.

陷阱二:CPU 密集计算霸占 worker

即便没有"阻塞调用",一段纯计算如果耗时很长(比如几百毫秒的图像处理,加密),同样会霸占 worker 线程不让出--因为计算中间没有 .await 让出点.

async fn handler() {
let hash = compute_heavy_hash(&data); // 耗时 500ms 纯计算,期间不让出
// 这 500ms 里,本 worker 上的其他任务全部得不到推进
}

解法同样是 spawn_blocking(或专门的 rayon 线程池):把 CPU 密集活儿移出异步 worker.这也呼应第 01 篇的结论--异步不擅长 CPU 密集,该交给线程.

陷阱三:持锁跨越 await

第 07,08 篇详谈过,这里作为陷阱再强调.持有 std::sync::MutexGuard.await 会编译失败(非 Send);但即便用 tokio::sync::Mutex 绕过编译,长时间持锁过 await 仍会扼杀并发--所有等锁的任务全堵住.

// 危险:持锁期间做慢 IO,锁被长期占用
let guard = mutex.lock().await;
slow_network_call().await; // 持锁等网络,其他任务全在排队抢锁

// 改进:能在锁外做的事,移出临界区
let data = { mutex.lock().await.clone() }; // 取需要的数据后立刻释放锁
slow_network_call_with(data).await;

陷阱四:忘记 .await

第 03 篇的"惰性"在实践中最常见的翻车现场:创建了 Future 却忘了 .await(也没 spawn),那段异步逻辑根本没执行.

async fn save(data: &str) { /* 写入数据库 */ }

async fn handler() {
save("important"); // 漏了 .await!Future 创建后即被丢弃,什么都没做
// 数据并未保存,且无任何运行时错误
}

靠编译器警告兜底

好在 Future 标了 #[must_use],这种情况编译器会给出 "unused implementer of Future" 警告.

务必把这类警告当错误对待,别让它淹没在输出里.这是 Go 没有的坑--Go 的 go f() 一写就跑,不存在"忘记触发".

陷阱五:误以为相邻 await 是并发

第 06,10 篇点过,这里归档为正式陷阱.两个相邻 .await顺序执行的,不会自动并发:

// 顺序:总耗时 = a + b
let x = fetch_a().await;
let y = fetch_b().await;

// 并发:总耗时 = max(a, b)
let (x, y) = tokio::join!(fetch_a(), fetch_b());

当两个操作互不依赖,本可并行时,写成相邻 await 就白白浪费了时间.想并发,用 join!spawn.

自查清单

症状 可能的坑 修复
并发数上去后整体卡顿,延迟飙升 阻塞调用 / CPU 霸占 worker 异步替代品或 spawn_blocking
高并发下吞吐不升反降,任务堆积抢锁 持锁过 await 缩短临界区,锁外做慢操作
某段异步逻辑"没生效",无报错 忘记 await .await,重视 must_use 警告
明明并发写法却和串行一样慢 相邻 await 未并发 改用 join! / spawn

快速回顾

  • 根因铁律:绝不在异步任务里阻塞 worker 线程;Tokio 不像 Go runtime 那样自动补救.
  • 阻塞调用:用异步替代品,或 spawn_blocking 把阻塞代码挪到专用线程池.
  • CPU 密集:无 await 让出点的长计算同样霸占 worker,交给 spawn_blocking/线程.
  • 持锁过 await:扼杀并发;缩短临界区,慢操作移出锁.
  • 忘记 await:Future 惰性,漏 await 等于没执行;把 must_use 警告当错误.
  • 相邻 await 非并发:互不依赖的操作用 join!/spawn 才真正并行.

动手练习

  1. 阻塞对比:写一个在异步任务里调用 std::thread::sleep 的程序,用多个并发任务观察它如何拖垮整体,再换 tokio::time::sleep 对比.
  2. spawn_blocking:把一段耗时纯计算用 spawn_blocking 移出 worker,验证其他任务恢复流畅.
  3. must_use 警告:故意写一个忘记 .await 的异步调用,确认编译器的 must_use 警告并修复.
  4. join! 并发:把两个独立的异步请求从相邻 await 改成 join!,计时对比耗时.
  5. 自查回顾:对照"自查清单",回顾练习中写过的代码,看是否踩过其中任何一条.