← 中文资源

简体中文版本

Tokio 运行时无法关闭 — 阻塞任务或泄漏的 spawn

通过找出在异步线程上的阻塞调用,以及让运行时保持存活的游离任务,诊断永不退出的 Tokio 运行时关闭。

阅读英文原文

症状

错误“Tokio 运行时无法关闭 — 阻塞任务或泄漏的 spawn”指向一个明确的失效模式。下面依次给出症状、定位该原因的检查命令、根本原因、修复方案,以及证明修复有效的验证步骤。

可观察到的症状包括:

  • main 返回后程序不退出,只能 Ctrl+C
  • 仅当某任务运行时关闭才挂起
  • multi_thread 的 tokio::main 挂起,而 current_thread 正常退出

诊断

先在隔离环境中复现故障,再按顺序执行以下检查,逐步缩小范围。

步骤 1

tokio-console # or RUST_LOG=tokio=debug cargo run

Tokio 运行时指标与 tokio-console 会显示轮询时长和卡在阻塞工作的任务。

步骤 2

RUST_BACKTRACE=1 timeout 15s cargo run

超时终止并附带 backtrace,会显示是哪个任务或阻塞调用阻止了退出。

常见诱因(按出现频率排序):

  • 在异步运行时上执行了阻塞调用,如同步 sleep 或同步文件读取
  • spawn 的任务无限循环,使运行时保持开启
  • JoinHandle 被丢弃后任务脱离,永不结束

根因

多线程运行时会一直运行直到所有 worker 停止;执行阻塞工作的任务占用 worker,脱离的循环任务使运行时无法进入空闲关闭状态,进程因此不退出。

修复

把阻塞工作移到 spawn_blocking,用 tokio::time::sleep 代替 std sleep,为长任务通过 CancellationToken 提供取消信号,并保留 JoinHandle 以便关闭时 await 或 abort。

注意:abort 任务可能在操作中途丢弃数据;优先使用带明确关闭信号的协作式取消。

use tokio::task;
use tokio_util::sync::CancellationToken;

#[tokio::main]
async fn main() {
    let token = CancellationToken::new();
    let handle = task::spawn({
        let token = token.clone();
        async move {
            loop {
                tokio::select! {
                    _ = token.cancelled() => break,
                    _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => { /* work */ }
                }
            }
        }
    });

    // never use std::thread::sleep or blocking std::fs in async code
    let _ = task::spawn_blocking(|| std::thread::sleep(std::time::Duration::from_millis(10))).await;

    token.cancel();
    let _ = handle.await; // graceful: task observes cancel and ends
}

验证

在 timeout 下运行并确认收到关闭信号后进程迅速退出;用关闭时排空的 JoinSet 断言无残留任务。

修复后补充一条回归测试,复现原始症状,防止问题再次出现。

这类故障通常是环境问题而非纯逻辑错误,因此在缺陷记录中应写明完整命令、环境变量与工作目录。请用最小的脚本复现,待最小用例清楚后再扩大范围。为这类故障准备一份简短的排查清单,能显著减少下次排障时间。