编程 async Rust 的工程陷阱:一次生产事故复盘

2026-09-01 00:06:10

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::writeRwLock::read

修法

  • 先拿数据,再拿锁,写完后立即释放,最后 .await
  • 确实需要把读锁升级为写锁的,用 RwLockUpgrade(Rust 1.74+)精细控制,并保证升级动作前后没有 .await

边界:锁临界区只做同步、短小操作。std::sync::RwLock 在 async 里更不能碰——它一 lock 就可能阻塞整个 worker 线程。

2 阻塞 I/O 卡死 executor:一个 sleep 干掉一切

成因:worker 线程是共享的。std::fs::read_to_stringstd::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 上不去。

修法

  • 并发收集结果用 FuturesUnorderedJoinSet
  • 限制并发数时,先获取共享 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::pinpin!
  • 自定义 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 线程池是可以优先考虑的简单方案。

复制全文 生成海报 async Rust Tokio 并发

推荐文章

程序员茄子在线接单