如何检测异步任务泄漏?

解读

在国内一线互联网或云原生公司的 Rust 面试中,**“异步任务泄漏”**通常指:

  1. 通过 tokio::spawn/async-std::task::spawn 创建的 Task 因未等待 JoinHandle而永远挂在后台;
  2. select!/join! 提前 drop 了某个分支,导致其内部子任务脱离父作用域
  3. 自定义 Runtime 在进程退出前未显式 block_on 或 shutdown,使得任务计数归零前进程就结束,线上日志表现为“任务已跑但 Prometheus 指标未回落”。

面试官想听到的是:能在编译期或运行期给出可观测、可报警、可灰度验证的方案,而不是“看内存涨没涨”这种模糊答案。

知识点

  1. Task 生命周期tokio::task::JoinHandle<T> 本质是 Rc<Inner>,被 drop 时仅取消任务,不会自动等待;
  2. 异步任务 IDtokio::task::id() nightly 已稳定,可配合 tracing 做链路追踪;
  3. Runtime 内部计数器tokio::runtime::Handle::metrics().active_tasks_count() 提供实时任务数
  4. RAII 包装:利用 Drop 在作用域结束时报出泄漏告警;
  5. 静态原子计数std::sync::atomic::AtomicUsizespawn+1,在 JoinHandleawait-1,通过 Prometheus 的 Gauge 指标暴露;
  6. 确定性测试#[tokio::test] 结束时调用 runtime.shutdown_timeout(Duration::from_secs(5)),若active_tasks_count() != 0panic!,可在 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::Runtimemetrics()

#[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,防止泄漏代码进入主干。

阶段三:生产可观测

  1. SPAWNED 封装成 prometheus::IntGauge 并注册到默认 Registry;
  2. 在 Grafana 面板上配置 active_task_gauge > 0 持续 5 min 即报警;
  3. 结合 tracingspawn 处注入 task_id,泄漏时通过 Jaeger 可快速定位到具体代码行
  4. 对于“detach 后不再关心”的极端场景,使用 tokio::task::unconstrained() 并显式标注 #[allow(clippy::async_yields_async)],在 code review 阶段人工二次确认。

通过上述手段,我们把“异步任务泄漏”从不可见变成可灰度、可回滚、可报警的普通质量指标,满足国内大型 Rust 服务对稳定性的严苛要求。

拓展思考

  1. 异步作用域(async-scoped):社区 crate async-scoped 通过 Scope::spawn 保证所有子任务在 scope 结束处被同步等待,可替代手动计数,但需引入额外依赖,面试时可讨论其零成本抽象是否可接受;
  2. LeakSanitizer 的异步适配:Google 内部已将 LSAN 与 tokio 集成,在 nightly 下可通过 -Z sanitizer=leak 捕捉 Task 未 join 的泄漏,国内可结合自研 CI 镜像落地;
  3. WebAssembly 场景:浏览器宿主没有标准线程,若 Rust-WASM 使用 wasm-bindgen-futures::spawn_local 泄漏,无法通过进程退出兜底,需要把 SPAWNED 导出到 JS 端,由前端在页面 beforeunload 时做最后一次断言,实现“跨语言泄漏检测”;
  4. 与 Go Goroutine 泄漏对比:Go 的 runtime.NumGoroutine() 相当于 active_tasks_count,但 Go 缺少编译期线性类型,Rust 借助所有权可在编译期杜绝部分泄漏,这是 Rust 在云原生赛道降本增效的核心卖点,面试结尾抛出可加深印象。