返回文章列表

tokio::spawn 不是后台线程:Rust 服务里的任务泄漏怎么防

310·3 分钟阅读
Rust微服务

异步任务不会因为你忘了它就消失。很多时候,它只是还在后台继续跑。

tokio::spawn 用起来太顺了:

tokio::spawn(async move {
    do_work().await;
});

一行代码,一个后台任务。日志轮询、指标上报、WebSocket 写循环、队列消费者、异步清理,全都能这么写。

问题也在这里:spawn 容易,管理难。

任务什么时候结束?
失败了谁知道?
请求取消后它还跑不跑?
进程关闭时等不等它?
JoinHandle 丢了会怎样?

异步任务泄漏不是内存泄漏那么直观,但结果一样:资源越来越多,行为越来越不可控。

这篇和 超时和取消优雅关闭 是一组。那两篇讲请求和进程怎么停,这篇专门讲 spawn 出去的任务怎么管。

本文代码环境:

# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
tracing = "0.1"

丢掉 JoinHandle,不等于取消任务

先看一个最容易误解的点:

async fn fire_and_forget() {
    let _handle = tokio::spawn(async {
        do_work().await;
    });
}

JoinHandle 被 drop,并不会取消任务。

它只是表示你不再关心这个任务的返回值。任务仍然会继续跑,直到自己结束、panic、runtime 关闭,或者被显式 abort。

这就带来一个问题:如果任务里面拿着连接、permit、文件句柄、channel sender,它们都会跟着任务继续活着。

fire-and-forget 不是不能用,但必须真的能 forget。

适合 fire-and-forget 的任务应该满足:

  • 生命周期很短
  • 不持有关键资源
  • 失败不影响业务
  • 不需要关闭时收尾

不满足这些条件,就要被管理。


请求里 spawn,最容易越过请求生命周期

HTTP handler 里直接 spawn 很危险:

async fn handler(user_id: u64) -> &'static str {
    tokio::spawn(async move {
        send_email(user_id).await;
    });
    "ok"
}

这段代码表达的是:请求返回后,邮件任务继续跑。

如果这正是你要的语义,可以。但很多时候这只是偷懒。请求失败、用户取消、服务关闭,都不会自然传到这个任务里。

更明确的写法是把后台任务交给一个队列或 Actor:

use tokio::sync::mpsc;
 
#[derive(Debug)]
struct EmailJob {
    user_id: u64,
}
 
async fn handler(tx: mpsc::Sender<EmailJob>, user_id: u64) -> Result<&'static str, &'static str> {
    tx.send(EmailJob { user_id })
        .await
        .map_err(|_| "email worker stopped")?;
    Ok("accepted")
}

这样至少有一个明确边界:请求只负责提交任务,worker 负责生命周期、重试和关闭。


长生命周期任务要有取消入口

长任务不要只靠 runtime 关闭来结束。

use tokio_util::sync::CancellationToken;
 
async fn metrics_loop(cancel: CancellationToken) {
    let mut tick = tokio::time::interval(std::time::Duration::from_secs(10));
    loop {
        tokio::select! {
            _ = cancel.cancelled() => break,
            _ = tick.tick() => flush_metrics().await,
        }
    }
    flush_metrics().await;
}

补一个模拟函数:

async fn flush_metrics() {}

取消入口的作用不是“让任务快点死”,而是给它一个收尾路径。

没有取消入口的循环,进程关闭时只能硬切;请求取消时它也不感知;测试里也很难稳定结束。

会活过当前请求的任务,都应该有取消入口。


多个任务用 JoinSet,不要散落一堆 handle

需要同时跑多个子任务时,JoinSetVec<JoinHandle<_>> 更容易管理。

use tokio::task::JoinSet;
 
async fn run_many() -> Result<(), &'static str> {
    let mut set = JoinSet::new();
    for i in 0..3 {
        set.spawn(async move {
            do_one(i).await;
            i
        });
    }
    while let Some(result) = set.join_next().await {
        tracing::info!(value = result.map_err(|_| "join error")?, "task done");
    }
    Ok(())
}

模拟子任务:

async fn do_one(i: u64) {
    tokio::time::sleep(std::time::Duration::from_millis(i)).await;
}

JoinSet 的好处是:你可以按完成顺序收结果,也能在作用域结束时集中处理剩余任务。

如果任务属于同一个生命周期,不要把 handle 散落在多个结构体和闭包里。


服务级任务用 TaskTracker

进程级后台任务更适合 TaskTracker

use tokio_util::task::TaskTracker;
 
#[derive(Clone)]
struct RuntimeTasks {
    tracker: TaskTracker,
    cancel: CancellationToken,
}

spawn 时统一登记:

impl RuntimeTasks {
    fn spawn_metrics(&self) {
        let cancel = self.cancel.clone();
        self.tracker.spawn(metrics_loop(cancel));
    }
}

关闭时统一等待:

impl RuntimeTasks {
    async fn shutdown(self) {
        self.tracker.close();
        self.cancel.cancel();
        self.tracker.wait().await;
    }
}

这和优雅关闭文章里的思路一致:不要让后台任务散在进程里。能登记,才能等待;能等待,才能知道它是否真的结束。


abort 是最后手段,不是默认关闭方式

JoinHandle::abort() 很有用,但不要默认依赖它。

async fn stop_task(handle: tokio::task::JoinHandle<()>) {
    handle.abort();
    let _ = handle.await;
}

abort 的语义是取消任务,让它在下一个 await 点停止。它不会帮你跑完整的业务收尾逻辑。

适合 abort 的场景:

  • 任务没有业务清理
  • 任务只是计算或等待
  • 外层已经做过补偿
  • 关闭超时后的兜底

不适合:

  • 需要 ack 消息
  • 需要 flush buffer
  • 需要释放业务租约
  • 需要写失败状态

默认用取消信号收尾,超时后再 abort。


任务泄漏要有指标

任务泄漏如果没有指标,很难第一时间发现。

至少监控这些:

指标 含义
spawned_total 创建了多少任务
running_tasks 当前还活着多少任务
task_errors_total 任务返回错误次数
task_panics_total JoinError 里的 panic
shutdown_wait_ms 关闭时等任务花了多久

任务不是越多越好。

如果某个服务正常情况下只有几十个后台任务,突然变成几千个,通常意味着连接没关、循环没退出、channel 没释放,或者请求里 spawn 的任务堆住了。


结论

tokio::spawn 创建的是需要生命周期管理的异步任务,不是扔出去就不用管的后台线程。

可以直接记这几条:

  • drop JoinHandle 不会取消任务
  • 请求里 spawn 会越过请求生命周期
  • 长生命周期任务要有取消入口
  • 同一组子任务用 JoinSet
  • 服务级后台任务用 TaskTracker
  • 默认取消收尾,超时后再 abort
  • 任务数量和退出耗时要有指标

只要任务会活过当前函数,就要有人负责它怎么结束。

Rust 能保证内存安全,但不会自动保证任务生命周期清楚。spawn 出去的每个任务,都应该能回答三个问题:谁取消它,谁等待它,失败了谁知道。