陷阱与选型
async 的高频坑(阻塞、取消安全)与线程/async 的选型判断。
常见错误与陷阱
async 块捕获引用导致 'static 报错
rust
// 依赖:tokio = { version = "1", features = ["full"] }
#[tokio::main]
async fn main() {
let name = String::from("worker");
// tokio::spawn(async { println!("{name}"); });
}text
error[E0373]: async block may outlive the current function, but it borrows `name`,
which is owned by the current function
--> src/main.rs:4:22
|
4 | tokio::spawn(async { println!("{name}"); });
| ^^^^^ ---- `name` is borrowed here
| |
| may outlive borrowed value `name`
|
note: async block is returned here
help: to force the async block to take ownership of `name` (and any other
variables referenced), you can use the `move` keyword
|
4 | tokio::spawn(async move { println!("{name}"); });
| ++++修法:
- 加
move:把所有权交给任务(最常见的修法)。 - 用
Arc:多个任务需要共享同一份数据时。 - 用
tokio::task::scope(尚未稳定,nightly)或join!:确需借用时, 让任务与作用域同生命周期。
跨 .await 持 MutexGuard:future cannot be sent between threads safely
rust
use std::sync::Mutex;
async fn bad(lock: &Mutex<u32>) -> u32 {
let guard = lock.lock().unwrap();
yield_once().await; // 守卫跨过了这个挂起点
*guard
}
async fn yield_once() {}
fn assert_send<T: Send>(_t: T) {}
fn main() {
let m = Mutex::new(1u32);
assert_send(bad(&m));
}rustc 1.98.1 的完整错误(这是本章最值得背下来的错误之一):
text
error: future cannot be sent between threads safely
--> src/main.rs:16:17
|
16 | assert_send(bad(&m));
| ^^^^^^^ future returned by `bad` is not `Send`
|
= help: within `impl Future<Output = u32>`, the trait `Send` is not implemented
for `std::sync::MutexGuard<'_, u32>`
note: future is not `Send` as this value is used across an await
--> src/main.rs:6:18
|
5 | let guard = lock.lock().unwrap();
| ----- has type `std::sync::MutexGuard<'_, u32>` which is not `Send`
6 | yield_once().await;
| ^^^^^ await occurs here, with `guard` maybe used later
|
note: required by a bound in `assert_send`在真实项目里,这个诊断出现在 tokio::spawn 的调用点:
text
error[E0277]: `std::sync::MutexGuard<'_, u32>` cannot be sent between threads safely
|
= help: within `{async block@src/main.rs:5:18}`, the trait `Send` is not
implemented for `std::sync::MutexGuard<'_, u32>`
note: future is not `Send` as this value is used across an await
note: required by a bound in `tokio::spawn`
|
= note: required because it appears within the type `{async block@...}`读法:
- 第一行告诉你哪个 future 不
Send。 help行告诉你哪个字段(这里是MutexGuard<'_, u32>)不Send。note: ... used across an await精确指到挂起点,并标出这个值是什么时候创建的。
修法:把守卫的作用域缩到 .await 之前(首选),或换 tokio::sync::Mutex(见「tokio::sync 与 std 对照」)。
在 async 里做阻塞操作会卡住整个 runtime
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::Duration;
use tokio::task::yield_now;
#[tokio::main(flavor = "current_thread")]
async fn main() {
tokio::spawn(async {
// 错:std::thread::sleep 会占住唯一的 worker 线程,
// 下面那个本该 10ms 打印的任务会被推迟到 3 秒之后
std::thread::sleep(Duration::from_secs(3));
});
tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(10)).await;
println!("this should print at ~10ms, but current_thread is blocked");
});
tokio::time::sleep(Duration::from_secs(4)).await;
let _ = yield_now().await;
}正确写法:
rust
// 依赖:tokio = { version = "1", features = ["full"] }
use std::time::Duration;
// 阻塞 IO / CPU 密集 → spawn_blocking
async fn hash_file(path: &str) -> std::io::Result<u64> {
let path = path.to_owned();
tokio::task::spawn_blocking(move || {
let data = std::fs::read(path)?;
// 假装这里是昂贵的哈希计算
Ok(data.iter().map(|b| u64::from(*b)).sum())
})
.await
.expect("blocking task panicked")
}
// 纯等待 → tokio::time::sleep(让出线程)
async fn polite_wait() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
#[tokio::main]
async fn main() {
let sum = hash_file("Cargo.toml").await.expect("read failed");
println!("{sum}");
polite_wait().await;
}⚠️ 陷阱:
#[tokio::main]默认是multi_thread,一个任务阻塞只会占住一个 worker, 症状更隐蔽(表现为「负载上不去、延迟尾部长」),比current_thread下直接卡死更难发现。 发现 runtime 里出现std::thread::sleep/std::fs::*/ 同步reqwest::blocking就要警惕。
? 在 async 中正常工作,但错误类型要统一
? 只是 match + From::from 的语法糖,与 async 无关,因此 async fn 里可以正常用:
rust
// 依赖:tokio = { version = "1", features = ["full"] }
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
// 实现 From 后,? 会自动转换错误类型
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e)
}
}
impl From<std::num::ParseIntError> for AppError {
fn from(e: std::num::ParseIntError) -> Self {
AppError::Parse(e)
}
}
async fn read_number(path: &str) -> Result<i32, AppError> {
let text = tokio::fs::read_to_string(path).await?; // io::Error -> AppError
let n: i32 = text.trim().parse()?; // ParseIntError -> AppError
Ok(n)
}
#[tokio::main]
async fn main() {
// 练习/示例里用 expect 说明期望值;生产代码请把 AppError 往上抛
match read_number("Cargo.toml").await {
Ok(n) => println!("{n}"),
Err(e) => println!("failed: {e:?}"),
}
}⚠️ 陷阱:
async不会改变?的规则,它只改变「函数体如何被编译成状态机」。 所以两件事要自己保证:
async fn的返回类型必须显式声明成Result<T, E>;写成async fn f() { ...? ... }会在?处报error[E0308]: mismatched types — expected '()', found 'Result<_, _>'。- 多个
?必须能转成同一个E,否则报error[E0277]: '?' couldn't convert the error to 'E',需要手写From(示例里那样)或用thiserror(库)/anyhow(应用)简化样板。另外
?在Option与Result混用时依然不行——这条规则与 async 无关。
忘记 .await:future 根本没执行
rust
async fn fetch(url: &str) -> String {
format!("GET {url}")
}
fn main() {
fetch("https://example.com"); // 只是构造了一个 future,然后丢掉
}text
warning: unused implementer of `Future` that must be used
--> src/main.rs:8:5
|
8 | fetch("https://example.com");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by defaultFuturetrait 本身带#[must_use],所以「构造即丢弃」至少有一条警告。- 但
let f = fetch(url);之后drop(f)是静默的:变量被使用了,没有警告。 这类 bug 的表现是「请求没发出去 / 文件没写 / 计数不对」,排查时先搜 「是不是漏了.await」。 clippy里let_underscore_future这一 lint 专门抓let _ = async_fn();。
递归 async 需要 Box::pin
rust
async fn countdown(n: u32) -> u32 {
if n == 0 { 0 } else { 1 + countdown(n - 1).await }
}text
error[E0733]: recursion in an async fn requires boxing
--> src/main.rs:1:1
|
1 | async fn countdown(n: u32) -> u32 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
4 | 1 + countdown(n - 1).await
| ---------------------- recursive call here
|
= note: a recursive `async fn` call must introduce indirection such as
`Box::pin` to avoid an infinitely sized future原因:状态机类型的大小必须在编译期确定,而递归调用意味着「状态机里嵌一个自己」, 大小无穷。修法是引入一层间接(indirection):
rust
// 依赖:无(纯 std)
use std::future::Future;
use std::pin::Pin;
fn countdown(n: u32) -> Pin<Box<dyn Future<Output = u32> + Send>> {
Box::pin(async move {
if n == 0 {
0
} else {
// 这里等待的是 Box::pin 出来的 future,大小固定为一个指针
1 + countdown(n - 1).await
}
})
}
fn main() {
fn assert_future<F: Future<Output = u32>>(_f: F) {}
assert_future(countdown(5));
}更贴近实战的递归形态(目录遍历、重试)通常改用显式栈或 futures::future::BoxFuture 别名:
rust
// 依赖:futures = "0.3"
use futures::future::BoxFuture;
use futures::FutureExt; // 提供 .boxed()
fn walk(path: String, depth: u32) -> BoxFuture<'static, Vec<String>> {
async move {
let mut out = vec![path.clone()];
if depth > 0 {
// 递归展开,每次调用返回一个装箱的 future
out.extend(walk(format!("{path}/sub"), depth - 1).await);
}
out
}
.boxed() // 等价于 Box::pin 的友好写法
}
fn main() {
let names = futures::executor::block_on(walk(String::from("root"), 2));
println!("{names:?} {}", names.len());
}输出:
["root", "root/sub", "root/sub/sub"] 3。
async fn 在 trait 中:1.75+ 能写,但 dyn 仍需变通
自 Rust 1.75 起,trait 里可以直接写 async fn(RPITIT 稳定),不再强制 #[async_trait]:
rust
// 依赖:无(需要 rustc >= 1.75)
trait Store {
async fn get(&self, key: &str) -> Option<String>;
async fn put(&self, key: &str, value: String);
}
struct MemStore;
impl Store for MemStore {
async fn get(&self, key: &str) -> Option<String> {
Some(format!("value of {key}"))
}
async fn put(&self, _key: &str, _value: String) {}
}
async fn use_generic<S: Store>(s: &S) {
// 泛型里可以直接调用,编译器为每个 S 生成专用状态机
if let Some(v) = s.get("k").await {
println!("{v}");
}
}
fn main() {
fn assert_future<F: std::future::Future>(_f: F) {}
assert_future(use_generic(&MemStore));
}限制与 workaround:
| 需求 | 1.75+ 直接写法 | 说明 |
|---|---|---|
泛型 fn f<S: Store>(s: &S) | ✅ 可以直接 .await | 单态化,零开销 |
dyn Store | ❌ 编译错误 | async fn 在 trait 里返回匿名类型,大小未知,不能进 vtable |
需要 dyn | 手写 -> Pin<Box<dyn Future<Output = T> + Send + '_>>,或在 impl 里 Box::pin | 多一次堆分配 |
| 只要省事 | #[async_trait] 宏(crate async-trait) | 宏自动改写为 Pin<Box<dyn Future>>,代价同上 |
手写 desugar 的样子:
rust
// 依赖:无。为了让 trait 能当 dyn 用,手动把 async fn 写成返回装箱 future
use std::future::Future;
use std::pin::Pin;
trait Store {
fn get<'a>(&'a self, key: &'a str) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>>;
}
struct MemStore;
impl Store for MemStore {
fn get<'a>(
&'a self,
key: &'a str,
) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
Box::pin(async move { Some(format!("value of {key}")) })
}
}
async fn use_dyn(store: &dyn Store) {
// dyn Store 现在可以用了
if let Some(v) = store.get("k").await {
println!("{v}");
}
}
fn main() {
fn assert_future<F: Future>(_f: F) {}
assert_future(use_dyn(&MemStore));
}trait 里的 async fn 默认不保证 Send,所以把它放进 tokio::spawn 会报错:
rust
trait Store {
async fn save(&self, key: &str);
}
async fn needs_send<S: Store + Sync>(s: &S) {
s.save("k").await;
}
fn assert_send<T: Send>(_t: T) {}
async fn generic<S: Store + Sync>(s: &S) {
assert_send(needs_send(s));
}text
error: future cannot be sent between threads safely
|
13 | assert_send(needs_send(s));
| ^^^^^^^^^^^^^ future returned by `needs_send` is not `Send`
|
= help: within `impl Future<Output = ()>`, the trait `Send` is not implemented
for `impl Future<Output = ()>`
note: future is not `Send` as it awaits another future which is not `Send`
--> src/main.rs:7:5
|
7 | s.save("k").await;
| ^^^^^^^^^^^ await occurs here on type `impl Future<Output = ()>`,
| which is not `Send`
help: `Send` can be made part of the associated future's guarantees for all
implementations of `Store::save`
|
3 - async fn save(&self, key: &str);
3 + fn save(&self, key: &str) -> impl std::future::Future<Output = ()> + Send;编译器给出的修法就是标准答案:用 impl Future + Send 写返回类型,把 Send 要求写进 trait 契约。若你希望「同一份 trait 定义生成 Send 版本和 !Send 版本」, nightly 的 trait_variant 宏正是干这个的:
rust
// ⚠️ nightly-only 示例:trait_variant 目前需要 nightly 工具链,正文主线不使用
#[trait_variant::make(SendStore: Send)]
trait Store {
async fn save(&self, key: &str);
}🚀 进阶:
trait_variant会把Store复制成「要求Send的SendStore」, 让库作者既能支持单线程轻量运行时,也能支持多线程 Tokio。
设计取舍:async、线程还是多进程
选型表
| 场景 | 首选 | 理由 |
|---|---|---|
| 大量并发网络 IO(网关、Web 服务、爬虫) | async + Tokio | 单线程可管理上万连接,内存按连接数十字节计 |
| 少量并发(< 几十)、任务较重 | 多线程 std::thread | 没有 async 传染性,调试简单(stack trace 完整) |
| CPU 密集(数值计算、编解码、压缩) | 多线程 + rayon | async 不会让 CPU 变快;要的是并行,不是挂起 |
| CPU 密集且需要 IO(批处理服务) | async 外壳 + spawn_blocking/rayon 内核 | 各用各的长处 |
| 一次性脚本、CLI(几分钟内跑完) | 同步代码 | async 的错误处理与生命周期成本不划算 |
| 极端隔离/容错/多核扩展 | 多进程 | 进程崩溃不拖垮全局;配合消息队列做水平扩展 |
| 嵌入式 / WASM | embassy / 无 OS executor | std 与 OS 线程都不可用 |
| 需要超时、取消、背压的组合 | async | select! / timeout / channel 背压是现成的 |
| 调用者几乎全是同步代码 | 同步 + 线程池 | 否则整条调用链都要 async 化 |
并发量到底需要多少
一个粗略的判断:用「同时在飞的 IO 请求数」而不是「QPS」来选模型。
- 峰值并发 < 一二百:
std::thread完全够用,代码更简单。 - 峰值并发上千:async 的收益开始明显(省内存、省切换)。
- 峰值并发上万:基本只有 async(或 Go 那样的有栈协程)扛得住。
「async 传染性」的现实影响
async 会沿调用链向上传染,这一点和 JS/Python 一样:
text
main <- 同步入口,只在这里 block_on 一次
|
+-- handler() async
|
+-- service() async
|
+-- repository() async
|
+-- driver() async <- 链上任意一层是同步的,就要在中间 block_on因此混合同步/异步的边界必须提前设计:
- 边界上游(同步世界调 async):只在
main、线程池任务的入口处block_on一次。 - 边界下游(async 调同步):
spawn_blocking,不要在 async 函数体里直接阻塞。 - 库作者:如果不知道用户用什么运行时,别依赖具体运行时的 IO 类型; 暴露
impl Future或接受&mut impl AsyncRead(tokio::io的 trait 有futures-io兼容层)。
什么时候不要用 async
- 代码里几乎没有 IO(纯计算):async 只增加复杂度和
Pin/Send报错,没有任何收益。 - 并发度很低(每秒钟几个请求):线程模型的简单性更值钱。
- 团队不熟悉:
Send报错、取消安全、Pin都会成为长期 bug 来源; 先用rayon+ 线程池解决,收益不够再引入 async。 - 需要「随时可中断、事务式回滚」的复杂业务逻辑:async 的取消语义是「drop」, 没有回滚,业务上需要自己做补偿。
与其他语言的对照
| 语言/机制 | 语法形态 | 调度方式 | 与 Rust 的关键差异 |
|---|---|---|---|
JS Promise / async | await fetch() | 运行时自带单线程 event loop | Rust future 是惰性的;Promise 创建即执行 |
TypeScript Promise<T> | 类型化 Promise | 同上 | Rust 用 impl Future<Output = T>,Output 是关联类型而非泛型参数 |
Python asyncio | await coro | 自带 event loop;GIL 使 CPU 无法并行 | Rust 无 GIL;future 可跨线程 Send,真并行 |
Python async def + TaskGroup | 结构化并发(3.11+) | event loop 调度 | Rust 的 JoinSet 类似,但取消语义是 drop |
C# Task<T> / async | await task | TaskScheduler + 线程池(内建) | Rust 无内建调度器;C# 的 Task 默认已在跑(hot task) |
C# ValueTask<T> | 避免分配 | 同上 | 接近 Rust 的「零分配状态机」,但仍是运行时驱动 |
Go goroutine | go f() + channel | 运行时 M:N 调度,有栈协程 | Rust 状态机无独立栈、更省内存;Go 的 goroutine 无法被外部取消 |
Go select | 多 channel 竞速 | 运行时 | tokio::select! 是宏,在编译期展开成 poll 状态机 |
| Java 虚拟线程(21+) | Thread.ofVirtual() | JVM 调度,有栈 | 无需改代码即可获得高并发;Rust 要显式 async 化 |
Java CompletableFuture | thenCompose 回调链 | ForkJoinPool | Rust 用 .await 线性写法,无回调金字塔 |
| Kotlin 协程 | suspend fun + launch | 由 Dispatchers 决定 | Kotlin 有结构化并发 coroutineScope;Rust 靠 JoinSet/scope |
Kotlin Flow | 冷流 + 操作符 | 协程调度 | 对应 futures::Stream,但 Rust 的 stream 不在 std |
| C++20 协程 | co_await | 不提供调度器 | 与 Rust 最像:语言只给机制,运行时自己接 |
| Erlang/Elixir 进程 | 抢占式轻量进程 | BEAM 调度 | Rust 的 future 是协作式:不 await 就不让出 |
💡 对照:最值得记住的一句话是——Rust 的 async 是「零成本抽象 + 外部运行时 + 无内置调度」。 语言只给你状态机和
Waker协议(和 C++20 同一路线), 调度策略、IO 多路复用、线程模型全部由 crate 决定; 而 Go/Java 虚拟线程选择「有栈协程 + 内建调度」,代价是内存开销和运行时不可替换。
速查表
| 写法 | 典型用法 | 要点 |
|---|---|---|
async fn f() -> T | 定义异步函数 | 返回 impl Future<Output = T>,惰性 |
fut.await | 等待结果 | 只能在 async 上下文里用;忘写则什么都不执行 |
.await? | 传播错误 | 需要统一错误类型或 From 转换 |
#[tokio::main] | 异步 main | 展开为建运行时 + block_on;(flavor = "current_thread") 可切单线程 |
#[tokio::test] | 异步测试 | 默认单线程运行时 |
Box::pin(fut) | 固定 future 地址 | 递归 async、结构体里存 future 时必需 |
tokio::spawn(async move { .. }) | 起并发任务 | 要求 'static + Send;返回 JoinHandle |
tokio::join!(a, b) | 并发等全部 | 同一任务内并发,可借用局部变量 |
tokio::try_join!(a, b) | 并发等全部,失败即返回 | 首个 Err 短路,其余 future 被 drop |
tokio::select! { .. } | 竞速取第一个完成 | 其余分支被 drop;biased; 改为按书写顺序 |
tokio::time::timeout(d, fut) | 加超时 | 返回 Result<T, Elapsed>;超时即 drop fut |
tokio::time::sleep(d) | 异步睡眠 | 让出线程;别用 std::thread::sleep |
tokio::task::spawn_blocking(f) | 跑阻塞代码 | 专用阻塞线程池;std::fs/FFI/重 CPU 都放这里 |
tokio::sync::mpsc::channel(n) | 异步队列 | n 是 buffer 容量,满则 send().await 形成背压 |
tokio::sync::oneshot::channel() | 一次性应答 | 常用于「任务完成后回传结果」 |
tokio::sync::Mutex::new(v) | 可跨 .await 的锁 | 无竞争时比 std 版慢;能缩小临界区就优先用 std 版 |
Arc<Semaphore>::acquire_owned() | 限制并发数 | permit drop 即释放;Arc 让 permit 能进 'static 任务 |
futures::stream::iter(..).buffered(n) | 限流并发流 | buffered 保序,buffer_unordered 保吞吐 |
while let Some(x) = stream.next().await | 消费流 | 需要 use futures::StreamExt 或 tokio_stream::StreamExt |