tokio::spawn 不是后台线程: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
需要同时跑多个子任务时,JoinSet 比 Vec<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 出去的每个任务,都应该能回答三个问题:谁取消它,谁等待它,失败了谁知道。