async Rust 的工程陷阱:一次生产事故复盘
某次线上事故:QPS 掉零,CPU 却有空闲,所有请求超时。tokio-console 里大量任务卡在 RwLock::write,worker 线程里有几个死在 std::fs::read_to_string 上。根因就两行代码:
- 热点接口持有
tokio::sync::RwLock写锁后跨.await等下游; - 定时任务在 async 上下文里同步读文件。
下面 8 个坑是这次排障和后续 review 的沉淀。原文未给出各坑的具体报错,下文“报错/现场信号”是实际工程中常见形态,供对照。
1 持锁跨 .await:写锁等读锁、读锁等写锁
成因:tokio::sync::RwLock 按任务排队。任务 A 拿到写锁后 .await,写锁一直不释放;任务 B 持有读锁的路径上也 .await 等别的事,读锁不释放。A 等读锁,B 等 A 的结果,形成互等。写锁在队列里永远等不到。
报错/现场信号:没有编译错误。请求超时,tokio-console / 线程栈能看到任务停在 RwLock::write 或 RwLock::read。
修法:
- 先拿数据,再拿锁,写完后立即释放,最后
.await。 - 确实需要把读锁升级为写锁的,用
RwLockUpgrade(Rust 1.74+)精细控制,并保证升级动作前后没有.await。
边界:锁临界区只做同步、短小操作。std::sync::RwLock 在 async 里更不能碰——它一 lock 就可能阻塞整个 worker 线程。
2 阻塞 I/O 卡死 executor:一个 sleep 干掉一切
成因:worker 线程是共享的。std::fs::read_to_string、std::thread::sleep 会让当前 worker 线程卡住,期间无法 poll 任何 Future。阻塞时间一长,线程池耗尽,整个 runtime 假死。
报错/现场信号:无编译错误。CPU 不饱和但请求全卡,线程栈能看到 read / nanosleep 类系统调用。
修法:
- 文件 I/O 用
tokio::fs。 - 阻塞操作 / CPU 密集操作用
tokio::task::spawn_blocking。 - 经验法则:一个调用预计 10ms 内不返回,就走
spawn_blocking。
边界:spawn_blocking 不是无底洞,也有线程池上限。CPU 密集任务更适合 rayon;如果项目允许,同步 + rayon 可能比 async 更简单。
3 for 循环逐条 .await:并发被串行化
成因:for id in ids { fetch(id).await } 是逐个等待,每个请求的等待时间直接相加,并发度完全浪费。
报错/现场信号:无编译错误。延迟随列表长度线性增长,QPS 上不去。
修法:
- 并发收集结果用
FuturesUnordered或JoinSet。 - 限制并发数时,先获取共享
Semaphore许可,再.await。
边界:下游并发敏感、或结果必须严格有序时,串行是合理的;但要么显式加并发上限,要么接受延迟。
4 Send 边界:Rc 跨 .await 编译失败
成因:多线程 executor 要求 Future 是 Send,因为任务可能在不同 worker 线程间迁移。Rc 不是 Send,跨 .await 持有 Rc 时编译器直接拒绝。
报错/现场信号(典型编译错误):
error: future cannot be sent between threads safely
= help: within `impl Future`, the trait `Send` is not implemented for `Rc`
修法:Rc 换成 Arc;不需要共享的,在 .await 前 drop。
边界:current_thread runtime 不需要 Send,但生产服务基本都是多线程 runtime,不要为了通过编译把 runtime 改成单线程。原文未提供此边界,这是工程取舍上的补充。
5 Pin 与自引用状态机:别手写 unsafe
成因:Future 内部如果存在自引用(例如局部变量被另一个字段借用),被 poll 后可能把指针交给 timer/executor。移动 Future 会让指针失效,产生未定义行为。
报错/现场信号:普通代码中编译器会要求 Pin>,或提示 cannot be unpinned;如果用了 Pin::new_unchecked,编译器不再拦,UB 就变成运行时随机崩溃。
修法:
- 业务代码用
Box::pin或pin!。 - 自定义 Future 结构里有 pinned 字段,用
pin-project生成安全投影。 - 不要裸写
Pin::new_unchecked。
边界:大部分业务代码不需要手动碰 Pin。这个坑主要属于手写 Future / Stream / 异步库作者;能改成无自引用的结构,优先改结构。
6 任务泄漏:spawn 后没人管
成因:tokio::spawn 返回 JoinHandle,丢了它任务照跑,但失去了等待、取消、错误处理能力。请求路径或循环里这样做,任务会无限累积,最终耗光内存、连接、文件描述符。
报错/现场信号:无编译错误。内存、连接数、文件描述符平稳上涨,runtime.metrics().num_alive_tasks() 持续增长。
修法:用 JoinSet 管理子任务,join_next() 回收;需要 fire-and-forget 时也要给任务数设上限并打点。
边界:fire-and-forget 不是反模式,但没有监控和上限就是事故。“每次请求 spawn 一个任务”通常是反模式。
7 Drop 不能 .await:清理逻辑必须显式
成因:Drop::drop 是同步函数,想在里面发关闭消息、flush 数据、释放异步锁都做不到。Rust 不允许 async fn drop。
报错/现场信号(典型编译错误):
error: functions in `Drop` impls cannot be async
(错误码可能随编译器版本变化,以本地输出为准。)
修法:提供 async fn close(&mut self) / async fn shutdown(&self),在业务代码显式调用;同步资源走 RAII,异步资源走显式生命周期。
边界:如果进程退出时能容忍清理丢失,可以不做显式 close;但不要试图在 Drop 里用 block_on 硬等——那会再次卡死 executor。
8 async fn in trait:dyn 和 Send 约束不好表达
成因:原生 async fn 在 trait 里虽然可用,但“返回的 Future 是 Send / 'static”没法在语法里表达,dyn Trait 仍受限。公开 API 一旦裸写,以后想加 Send 或做 trait object,就得破坏性变更。
报错/现场信号:原文未提供具体报错。实际中可能在 dyn 调用点报 trait 不是 object-safe,或者在要求 Send 的边界处报 future 不满足 Send。
修法:
- 需要 trait object / Send bound 的,用
#[async_trait]或#[trait_variant::make(...)]。 - 纯泛型内部 trait,裸
async fn简洁可用;公开 API 慎用。
边界:库内部、不要求 Send、不需要 dyn 的场景,裸 async fn 没问题;一旦进入公共 API,优先写 fn foo(&self) -> impl Future + Send 或走宏。
这 8 个坑有一个共性:在异步环境里做了同步假设。大部分生产事故不是 Future 原理没学透,而是锁、阻塞 I/O、任务管理和 trait 边界的取舍出了问题。
async Rust 不是银弹。如果项目允许,同步 + rayon 线程池是可以优先考虑的简单方案。