如何检测异步任务泄漏?
解读
在国内一线互联网或云原生公司的 Rust 面试中,**“异步任务泄漏”**通常指:
- 通过
tokio::spawn/async-std::task::spawn创建的 Task 因未等待 JoinHandle而永远挂在后台; select!/join!提前 drop 了某个分支,导致其内部子任务脱离父作用域;- 自定义
Runtime在进程退出前未显式 block_on 或 shutdown,使得任务计数归零前进程就结束,线上日志表现为“任务已跑但 Prometheus 指标未回落”。
面试官想听到的是:能在编译期或运行期给出可观测、可报警、可灰度验证的方案,而不是“看内存涨没涨”这种模糊答案。
知识点
- Task 生命周期:
tokio::task::JoinHandle<T>本质是Rc<Inner>,被 drop 时仅取消任务,不会自动等待; - 异步任务 ID:
tokio::task::id()nightly 已稳定,可配合tracing做链路追踪; - Runtime 内部计数器:
tokio::runtime::Handle::metrics().active_tasks_count()提供实时任务数; - RAII 包装:利用
Drop在作用域结束时报出泄漏告警; - 静态原子计数:
std::sync::atomic::AtomicUsize在spawn时+1,在JoinHandle被await后-1,通过 Prometheus 的 Gauge 指标暴露; - 确定性测试:
#[tokio::test]结束时调用runtime.shutdown_timeout(Duration::from_secs(5)),若active_tasks_count() != 0 则panic!,可在 CI 中硬阻塞合并。
答案
我采用“静态计数 + RAII + Runtime 指标”三板斧,在开发、测试、生产三阶段分别给出不同粒度的泄漏证据。
阶段一:开发自测
use std::sync::atomic::{AtomicUsize, Ordering};
static SPAWNED: AtomicUsize = AtomicUsize::new(0);
fn tracked_spawn<F>(f: F) -> tokio::task::JoinHandle<F::Output>
where F: std::future::Future + Send + 'static,
F::Output: Send + 'static,
{
SPAWNED.fetch_add(1, Ordering::Relaxed);
tokio::spawn(async move {
let out = f.await;
SPAWNED.fetch_sub(1, Ordering::Relaxed);
out
})
}
在单元测试末尾断言 SPAWNED.load(Ordering::Relaxed) == 0,编译期即可拒绝泄漏提交。
阶段二:集成测试
利用 tokio::runtime::Runtime 的 metrics():
#[tokio::test]
async fn no_leak() {
let rt = tokio::runtime::Runtime::new().unwrap();
let handle = rt.handle().clone();
rt.block_on(async {
// 业务代码
});
rt.shutdown_timeout(std::time::Duration::from_secs(5));
assert_eq!(handle.metrics().active_tasks_count(), 0);
}
若 CI 失败,直接阻塞 MR,防止泄漏代码进入主干。
阶段三:生产可观测
- 将
SPAWNED封装成prometheus::IntGauge并注册到默认 Registry; - 在 Grafana 面板上配置 active_task_gauge > 0 持续 5 min 即报警;
- 结合
tracing在spawn处注入task_id,泄漏时通过 Jaeger 可快速定位到具体代码行; - 对于“detach 后不再关心”的极端场景,使用
tokio::task::unconstrained()并显式标注#[allow(clippy::async_yields_async)],在 code review 阶段人工二次确认。
通过上述手段,我们把“异步任务泄漏”从不可见变成可灰度、可回滚、可报警的普通质量指标,满足国内大型 Rust 服务对稳定性的严苛要求。
拓展思考
- 异步作用域(async-scoped):社区 crate
async-scoped通过Scope::spawn保证所有子任务在scope结束处被同步等待,可替代手动计数,但需引入额外依赖,面试时可讨论其零成本抽象是否可接受; - LeakSanitizer 的异步适配:Google 内部已将 LSAN 与
tokio集成,在 nightly 下可通过-Z sanitizer=leak捕捉 Task 未 join 的泄漏,国内可结合自研 CI 镜像落地; - WebAssembly 场景:浏览器宿主没有标准线程,若 Rust-WASM 使用
wasm-bindgen-futures::spawn_local泄漏,无法通过进程退出兜底,需要把SPAWNED导出到 JS 端,由前端在页面beforeunload时做最后一次断言,实现“跨语言泄漏检测”; - 与 Go Goroutine 泄漏对比:Go 的
runtime.NumGoroutine()相当于active_tasks_count,但 Go 缺少编译期线性类型,Rust 借助所有权可在编译期杜绝部分泄漏,这是 Rust 在云原生赛道降本增效的核心卖点,面试结尾抛出可加深印象。