DEBUG.WIKI
雨云;给项目一台云服务器
晚安云;香港 CN2 ¥15/月
京东云;让 AI 进入真实业务;零售 · 物流 · 客服 · 硬件;查看方案 →

Tokio 任务报 span already closed:检查跨 await 的 enter guard

tracing-subscriber 0.3.23 的关闭 span 断言可能由跨 await 的 enter guard 触发。改用 Instrument 绑定 future 轮询,检查任务结果与追踪父子关系。

更新 tracing-subscriber 后,Tokio 任务在 Span::current() 或创建 span 时 panic,报错包含:

tried to clone a span (...) that already closed

若代码里存在 let _entered = span.enter();,而这个 guard 的生命周期跨过了 .await,先修正 span 的使用范围。tracing 0.1.44 配 tracing-subscriber 0.3.22、0.3.23 有这一具体触发场景;旧版不 panic,并不能证明这项用法正确。

enter 的范围属于当前线程,不属于整个异步任务

span.enter() 返回的 guard 在释放时退出 span。同步调用中,这可以围住一段连续执行的代码;异步任务遇到 await 后却可能暂停,让别的任务在当前线程运行,也可能在另一个工作线程恢复。

guard 仍活着,不表示原任务一直占着那个线程。结果可能是某个线程仍把已经关闭的 span 当作当前 span,另一条任务随后读取或克隆它时才暴露异常。报错的 Span::current() 因此不一定就是最先误用 enter 的位置。

排查时搜索进入 span 的所有位置,检查 guard 的实际结束范围。不要只给发生 panic 的 Span::current 外面加捕获,也不要只删除这条日志;不平衡的当前 span 状态可能继续影响其他任务。

将 span 绑定到 future 的每次轮询

跨异步挂起的范围使用 Instrument。下面只演示一个任务的绑定方式,subscriber 仍按应用原来的初始化入口配置:

use tracing::{info_span, Instrument};

async fn worker() {
    tokio::task::yield_now().await;
    tracing::info!("worker resumed");
}

async fn run_worker() -> Result<(), tokio::task::JoinError> {
    let task = tokio::spawn(
        worker().instrument(info_span!("worker")),
    );
    task.await
}

Instrument 在 future 被轮询时进入 span,在这次轮询结束时退出,因此不会让 span 的进入状态一直占住等待期间的线程。原来跨 await 保存的 enter guard 应移除;仅给外层再套一个 instrument,而保留错误 guard,不是完整修正。

如果只需要给一小段同步准备工作设置 span,可在明确的同步作用域里进入,并在 await 之前结束。把“创建 future 的同步动作”放进作用域,不等于把 future 后续执行也纳入了这个 span。

已有父 span、任务命名和字段应按应用需要保留。独立 spawn 的任务是否继承当前上下文,也要显式检查,不能只让 panic 消失,却把所有事件归到一个错误父节点。

单线程运行时也不是修复办法

即使任务不会换线程,await 期间仍可能交错运行其他任务。把 Tokio 改成 current-thread,或暂时降低并发,可能改变复现概率,却没有使跨 await 的 enter guard 获得正确语义。

验收时保留会发生挂起和交错的任务路径,核对任务退出结果、日志中的 span 关系,以及正常、取消和错误分支。不要忽略 JoinHandle 后只看主进程仍在运行;一个后台任务已经 panic,主进程也可能继续存活。

较新的依赖可能改变断言或报错信息,但不能替应用修正 guard 范围。不要删除注册表里的断言、吞掉所有 panic 或仅回退到不报错的旧版本,再把缺少异常当作追踪上下文已经正确。