Instrument 与异步
主线位置:span 主线在异步代码里的样子。同步代码里一个 span 进入一次、退出一次;
Instrumented<F>把「进入」拆到每一次poll上:poll 前enter,poll 返回时exit,future 被丢掉时再进入一次。事件主线不变,只是「当前 span」由每次 poll 临时设好。
sequenceDiagram participant rt as 运行时 participant ins as Instrumented participant sub as subscriber participant fut as 内层 future rt->>ins: poll ins->>sub: enter(id) ins->>fut: poll fut-->>ins: Pending ins->>sub: exit(id) ins-->>rt: Pending rt->>ins: poll(被唤醒后) ins->>sub: enter(id) ins->>fut: poll fut-->>ins: Ready ins->>sub: exit(id) rt->>ins: drop ins->>sub: enter(id) ins->>fut: drop 内层 ins->>sub: exit(id) ins->>sub: try_close(id)
上一页的 enter 返回一个守卫,守卫活多久,span 就进入多久。async 代码里这个对应关系不成立:一个 async fn 在 .await 处返回 Pending,线程去跑别的任务,而函数里的局部变量(包括守卫)还活在 future 的状态里。本页讲 tracing 给异步代码的两个包装:instrument.rs 里的 Instrumented 和 WithDispatch,以及为什么不能用守卫代替它们。poll 和唤醒本身见 tokio 卷的 poll 与唤醒;#[instrument] 修饰 async fn 时就是生成一个 .instrument(span),见 展开成什么。
Instrument trait
Instrument 是一个扩展 trait,两个方法都有默认实现,并且对所有类型实现:impl<T: Sized> Instrument for T {}。
pub trait Instrument: Sized {
fn instrument(self, span: Span) -> Instrumented<Self> {
Instrumented {
inner: ManuallyDrop::new(self),
span,
}
}
#[inline]
fn in_current_span(self) -> Instrumented<Self> {
self.instrument(Span::current())
}
}
instrument 只是把 future 和 span 装进一个结构体,什么回调也不调;span 在这之前已经由 info_span! 创建好了。所以 .instrument(info_span!("req")) 里 span 的父是调用 instrument 那一刻的当前 span,而不是 future 第一次被 poll 时的当前 span。
in_current_span 是 instrument(Span::current()):不新建 span,把当前 span 的一个句柄(current_span 加 clone_span,见 Span 与 Entered)交给 future。典型用法是 spawn 之前把「我现在所在的 span」带进新任务。Span::current() 拿不到 span 时是 Span::none(),这时 Instrumented 每次 poll 的进出都是空操作。
instrument 的文档还提醒了一个组合:.instrument(debug_span!("child")) 在 debug 被过滤时,任务不在任何 span 里;想退回到当前 span,要写 .instrument(debug_span!("child").or_current()),而不是在外面再套 .in_current_span()。
Instrumented:每次 poll 进出
Instrumented 用 pin-project-lite 的 pin_project! 宏声明,同一个宏里给了一个 PinnedDrop:
pin_project! {
#[project = InstrumentedProj]
#[project_ref = InstrumentedProjRef]
#[derive(Debug, Clone)]
#[must_use = "futures do nothing unless you `.await` or poll them"]
pub struct Instrumented<T> {
// `ManuallyDrop` is used here to to enter instrument `Drop` by entering
// `Span` and executing `ManuallyDrop::drop`.
#[pin]
inner: ManuallyDrop<T>,
span: Span,
}
impl<T> PinnedDrop for Instrumented<T> {
fn drop(this: Pin<&mut Self>) {
let this = this.project();
let _enter = this.span.enter();
// SAFETY: 1. `Pin::get_unchecked_mut()` is safe, because this isn't
// different from wrapping `T` in `Option` and calling
// `Pin::set(&mut this.inner, None)`, except avoiding
// additional memory overhead.
// 2. `ManuallyDrop::drop()` is safe, because
// `PinnedDrop::drop()` is guaranteed to be called only
// once.
unsafe { ManuallyDrop::drop(this.inner.get_unchecked_mut()) }
}
}
}
poll:
impl<T: Future> Future for Instrumented<T> {
type Output = T::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let (span, inner) = self.project().span_and_inner_pin_mut();
let _enter = span.enter();
inner.poll(cx)
}
}
_enter 是普通的 Entered,它的生命周期正好是这一次 poll 调用:内层 future 跑到下一个 .await 返回 Pending,poll 返回,守卫 drop,exit。守卫从不跨过 .await,因为 Instrumented::poll 本身是同步函数。线程接着去跑别的任务时,这个 span 已经出栈了。
inner 是 ManuallyDrop<T>,这是为了 Drop。字段的析构顺序由编译器决定,Drop::drop 返回之后字段才被析构,在 drop 里进入 span 也管不到之后才发生的 inner 析构。把 inner 包进 ManuallyDrop,编译器就不会自动析构它,PinnedDrop::drop 先 enter,再手动 ManuallyDrop::drop,内层 future 里还活着的局部变量就在 span 里析构,它们 Drop 里打的日志带着这个 span。span 字段本身在 drop 返回后照常析构,也就是 try_close。
tracing 0.1.38 之前,Instrumented 没有自己的 Drop,inner 是普通的 #[pin] inner: T。future 跑完时,局部变量在最后一次 poll 里析构,那时 span 是进入的;future 没跑完就被丢掉(超时、select! 的另一个分支先完成、任务被取消)时,局部变量随 Instrumented 的字段析构,那时这个 span 并没有进入,日志落在 drop 发生处的当前 span 里(往往是没有 span)。#2541 提出了这个不一致:「The problem is there is difference between when Drop can be actually called: inside Future::poll() or on Drop of the entire Future」。
#2562 的做法是「Wrap inner Future of Instrumented in ManuallyDrop to execute inner Drop with entered Span」,也就是上面的 PinnedDrop。同一个 PR 改了 #[instrument] 的测试期望:每个 async fn 的 span 在 drop_span 之前多了一对 enter / exit。PR 合进 master,0.1 线上的提交是 3a653548,首次发布于 tracing 0.1.38,CHANGELOG 写的是:「Instrumented futures will now enter the attached Span in their Drop implementation, allowing events emitted when dropping the future to occur within the span」。
代价是两处 unsafe:span_and_inner_pin_mut 用 map_unchecked_mut 从 Pin<&mut ManuallyDrop<T>> 投影到 Pin<&mut T>;into_inner 不能直接解构一个实现了 Drop 的结构体,只好把整个 Instrumented 放进 ManuallyDrop,再用裸指针把两个字段分别读出来。
Instrumented<T> 只在 T: Future 时实现 Future。Instrument 对所有类型实现,所以给一个不是 future 的值调 .instrument(span) 也能编译,只是得到的东西不能 .await。
enter(req) 会出现几次?tracing = "=0.1.44"
tracing-core = "=0.1.36"
tokio = { version = "=1.54.1", features = ["macros", "rt", "time"] }
printer.rs 就是 Span 与 Entered 页那个每个回调打印一行的 subscriber。
#[path = "../printer.rs"]
mod printer;
use std::time::Duration;
use tracing::{Instrument, info, info_span};
// 析构时打一条日志
struct Noisy;
impl Drop for Noisy {
fn drop(&mut self) {
info!("Noisy dropped");
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing::subscriber::set_global_default(printer::Printer::new()).unwrap();
let fut = async {
let _n = Noisy;
info!("poll 1");
tokio::task::yield_now().await;
info!("poll 2");
std::future::pending::<()>().await;
}
.instrument(info_span!("req"));
println!("[timeout 开始]");
let r = tokio::time::timeout(Duration::from_millis(10), fut).await;
println!("[timeout 返回 {:?}]", r.is_err());
}
future 永远不会完成,timeout 在 10ms 后放弃它并把它 drop。输出里 enter(req) 有几行?Noisy dropped 这条事件在不在 req 里?
为什么跨 .await 持有 Entered 是错的
enter 的文档里有一节「In Asynchronous Code」:
/// The drop guard returned by `Span::enter` exits the span when it is
/// dropped. When an async function or async block yields at an `.await`
/// point, the current scope is _exited_, but values in that scope are
/// **not** dropped (because the async block will eventually resume
/// execution from that await point). This means that _another_ task will
/// begin executing while _remaining_ in the entered span. This results in
/// an incorrect trace.
下面把它跑出来。两个任务各自建一个 span、enter、打一条日志、yield_now 让出、再打一条;第三个任务自己没有 span,只打一条日志。全部跑在 current_thread 运行时上,同一个线程。然后用 .instrument 的写法再跑一遍。
tracing = "=0.1.44"
tracing-subscriber = "=0.3.23"
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use tracing::{Instrument, info, info_span};
// 错误写法:守卫跨 .await
async fn wrong(id: u32) {
let span = info_span!("task", id);
let _g = span.enter();
info!(who = id, "start");
tokio::task::yield_now().await;
info!(who = id, "after yield");
}
// 正确写法:span 交给 Instrumented,每次 poll 进出
async fn right(id: u32) {
async {
info!(who = id, "start");
tokio::task::yield_now().await;
info!(who = id, "after yield");
}
.instrument(info_span!("task", id))
.await
}
// 旁观者:自己没有任何 span
async fn bystander() {
info!(who = "c", "no span here");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt()
.with_ansi(false)
.without_time()
.with_target(false)
.init();
info!("--- enter() 跨 .await");
let tasks = [
tokio::spawn(wrong(1)),
tokio::spawn(wrong(2)),
tokio::spawn(bystander()),
];
for t in tasks {
t.await.unwrap();
}
info!("--- .instrument()");
let tasks = [
tokio::spawn(right(1)),
tokio::spawn(right(2)),
tokio::spawn(bystander()),
];
for t in tasks {
t.await.unwrap();
}
info!("--- 结束");
}
M2 Max、rustc 1.99.0、--release,跑 5 次输出相同:
INFO --- enter() 跨 .await
INFO task{id=1}: start who=1
INFO task{id=1}:task{id=2}: start who=2
INFO task{id=1}:task{id=2}: no span here who="c"
INFO task{id=1}:task{id=2}: after yield who=2
INFO task{id=1}: after yield who=1
INFO --- .instrument()
INFO task{id=1}: start who=1
INFO task{id=2}: start who=2
INFO no span here who="c"
INFO task{id=2}: after yield who=2
INFO task{id=1}: after yield who=1
INFO --- 结束
who 字段是真实的发出者,行首的 span 链是 fmt 认定的上下文。错误写法下,按 Registry 那个线程局部的 span 栈(见 Registry)一步步看:
| 时刻 | 发生了什么 | span 栈 | 输出 |
|---|---|---|---|
| 任务 1 第一次 poll | 建 span 1、enter | [1] | task{id=1}: start |
任务 1 在 yield_now 返回 Pending | 守卫 _g 还在 future 里,没有 exit | [1] | |
| 任务 2 第一次 poll | 建 span 2,当前 span 是 1,于是 1 成了 2 的父;enter | [1, 2] | task{id=1}:task{id=2}: start |
| 任务 2 让出 | 同样没有 exit | [1, 2] | |
| 旁观者 poll | 它没有 span,事件以栈顶 2 为父 | [1, 2] | task{id=1}:task{id=2}: no span here |
| 任务 2 恢复、结束 | 打日志,然后 _g drop,exit(2) | [1] | ... after yield who=2 |
| 任务 1 恢复、结束 | 栈顶碰巧是 1 | [] | task{id=1}: after yield |
三处错误:span 2 被记成 span 1 的子 span,这个父子关系一旦在 new_span 时定下就改不了;旁观者那条事件被挂到两个和它无关的 span 下;任务 2 的所有日志都多了一个 task{id=1} 前缀。任务 1 最后那行是对的,只是因为任务 2 恰好先恢复、先退出;哪个任务先恢复由调度器决定(见 tokio 卷的 current_thread 调度器),换一个顺序,错的就是任务 1。
.instrument 的写法下,每个任务在 poll 返回时已经退出了自己的 span,栈在任务之间总是空的。span 2 创建时也没有当前 span,父子关系是对的。
多线程运行时更糟:任务在 .await 之后可能换到另一个工作线程上恢复,守卫最后在另一个线程上 drop,exit 去一个从没压入过这个 Id 的栈里找。Registry 的 SpanStack::pop 从栈顶往下找这个 Id,找不到就什么也不做;原来那个线程的栈里则一直留着它,之后在那个线程上跑、自己没有进入别的 span 的代码,都会以它为当前 span。Entered 是 Send 的,编译器不会拦(原因见 Span 与 Entered);EnteredSpan 不是 Send,在 tokio::spawn 里会直接编译失败。
clippy 能不能查出来
clippy 有 await_holding_lock、await_holding_refcell_ref 这样按类型命名的 lint,照这个命名,最直接的猜测是 await_holding_span_guard。用本机的 clippy 0.1.99(随 rustc 1.99.0)对一段跨 .await 持有守卫的代码显式打开它:
use tracing::info_span;
async fn work() {}
async fn hold_entered() {
let span = info_span!("a");
let _g = span.enter();
work().await;
}
async fn hold_entered_span() {
let _g = info_span!("b").entered();
work().await;
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
hold_entered().await;
hold_entered_span().await;
}
不加参数跑 cargo clippy,没有任何警告。加上 -- -W clippy::await_holding_span_guard:
warning[E0602]: unknown lint: `clippy::await_holding_span_guard`
|
= help: did you mean: `clippy::await_holding_lock`
= note: requested on the command line with `-W clippy::await_holding_span_guard`
= note: `#[warn(unknown_lints)]` on by default
这个版本的 clippy 里没有这个 lint。能做到同样事情的是通用的 await_holding_invalid_type:它检查「不应跨 .await 持有的类型」,类型列表由 clippy.toml 的 await-holding-invalid-types 配置,默认是空列表(cargo clippy --explain await_holding_invalid_type 写的是 (default: [])),所以默认什么也查不出来。在 crate 根目录放一个 clippy.toml:
await-holding-invalid-types = [
{ path = "tracing::span::Entered", reason = "用 Instrument::instrument 代替" },
{ path = "tracing::span::EnteredSpan", reason = "用 Instrument::instrument 代替" },
]
再跑 cargo clippy,不需要额外参数:
warning: holding a disallowed type across an await point `tracing::span::Entered`
--> src/bin/clippy_guard.rs:7:7
|
7 | let _g = span.enter();
| ^^
|
= note: 用 Instrument::instrument 代替
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#await_holding_invalid_type
= note: `#[warn(clippy::await_holding_invalid_type)]` on by default
warning: holding a disallowed type across an await point `tracing::span::EnteredSpan`
--> src/bin/clippy_guard.rs:12:7
|
12 | let _g = info_span!("b").entered();
| ^^
|
= note: 用 Instrument::instrument 代替
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#await_holding_invalid_type
lint 本身默认是 warn,配置了类型就生效。两个守卫都要列出来:entered() 返回的是 EnteredSpan,只列 Entered 查不到第二个函数。
tracing 0.1.44 的文档里还没有这段建议。#3408 提出「Recommend enabling the await-holding-invalid-types lint」,#3463 在 2026 年 4 月把同样两条配置写进了 Span::enter 的「In Asynchronous Code」一节,这个提交在 tag tracing-0.1.44 之后,到本卷写作时还没有随任何 tracing 版本发布。
WithDispatch:把 dispatcher 带进任务
Instrumented 解决的是「当前 span」,不解决「当前 subscriber」。span 的 enter / exit 走 span 自己存的 Dispatch(见 Span 与 Entered),但 future 里的 info! 每次都通过 dispatcher::get_default 找当前线程的默认 subscriber。用 set_default 装的线程默认只对当前线程有效(见 Dispatch:全局与线程局部),任务被 spawn 到工作线程上,事件就找不到它。
WithSubscriber 是第二个扩展 trait,同样对所有类型实现。with_current_subscriber 在调用的那一刻取当前默认的 dispatcher,装进 WithDispatch;with_subscriber(s) 则用指定的那个:
#[inline]
fn with_current_subscriber(self) -> WithDispatch<Self> {
WithDispatch {
inner: self,
dispatcher: dispatcher::get_default(|default| default.clone()),
}
}
它的 poll 和 Instrumented 同一个模式,只是把 enter 换成了 set_default:
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
let dispatcher = this.dispatcher;
let future = this.inner;
let _default = dispatcher::set_default(dispatcher);
future.poll(cx)
}
每次 poll 期间,这个线程的默认 dispatcher 被换成带来的那个,poll 返回时 DefaultGuard drop,换回原来的。WithDispatch 没有自己的 Drop,future 被丢掉时析构里的日志不经过它。
在多线程运行时上对比四种写法:
tracing = "=0.1.44"
tracing-core = "=0.1.36"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
#[path = "../printer.rs"]
mod printer;
use tracing::{Instrument, info, info_span, instrument::WithSubscriber};
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() {
// 只对 main 所在的线程生效;spawn 出去的任务跑在工作线程上
let _guard = tracing::subscriber::set_default(printer::Printer::new());
println!("[1] spawn(async {{ info!(..) }})");
tokio::spawn(async { info!("plain") }).await.unwrap();
println!("[2] spawn(async {{ info!(..) }}.instrument(span))");
tokio::spawn(async { info!("instrumented") }.instrument(info_span!("req")))
.await
.unwrap();
println!("[3] spawn(async {{ info!(..) }}.with_current_subscriber())");
tokio::spawn(async { info!("with dispatch") }.with_current_subscriber())
.await
.unwrap();
println!("[4] instrument(span).with_current_subscriber()");
tokio::spawn(
async { info!("both") }
.instrument(info_span!("req2"))
.with_current_subscriber(),
)
.await
.unwrap();
}
跑 3 次输出相同:
[1] spawn(async { info!(..) })
[2] spawn(async { info!(..) }.instrument(span))
new_span(req) -> 1
enter(req)
exit(req)
enter(req)
exit(req)
try_close(req) refs=0 -> true
[3] spawn(async { info!(..) }.with_current_subscriber())
event message=with dispatch (in -)
[4] instrument(span).with_current_subscriber()
new_span(req2) -> 2
enter(req2)
event message=both (in req2)
exit(req2)
enter(req2)
exit(req2)
try_close(req2) refs=0 -> true
- [1] 事件在工作线程上发出,那里没有线程默认,全局默认也没设,交给了
NoSubscriber,什么都没有。 - [2] 最能说明问题:span 是在 main 线程上创建的,
Inner里存着Printer,所以工作线程上的enter/exit/try_close都到了Printer;但中间的info!查的是工作线程的默认,丢了。两对enter/exit:第一对是那一次 poll,第二对是 future 跑完后被 drop 时PinnedDrop的那一次。 - [3] 事件到了,但不在任何 span 里。
- [4] 两个都要:
Instrumented管 span,WithDispatch管事件找到谁。
用 set_global_default(或 fmt().init())装 subscriber 的程序不需要 WithDispatch:全局默认在所有线程上都可见。它是给「线程默认」准备的,比如测试里用 set_default 为每个测试装一个 subscriber,又要 spawn 任务。
问: Instrumented 每次 poll 都要进 span,为什么不顺手把 span 里那个 Dispatch 也 set_default 一下,省得用户再套一层 WithDispatch?
答: 那样每个被 instrument 的 future 每次 poll 都要做一次 set_default。set_default 要写线程局部变量,还会把全局计数 SCOPED_COUNT 加一;只要它不为 0,进程里所有线程上的每一次 get_default 都不能走「直接读全局」的快路径,要去查线程局部(见 Dispatch:全局与线程局部)。绝大多数程序用的是全局默认,根本不需要它。把两件事拆成两个包装,只在真需要线程默认的地方付这份代价。
动脑筋答案
M2 Max、rustc 1.99.0、--release,跑 3 次输出相同:
new_span(req) -> 1
[timeout 开始]
enter(req)
event message=poll 1 (in req)
exit(req)
enter(req)
event message=poll 2 (in req)
exit(req)
enter(req)
exit(req)
enter(req)
event message=Noisy dropped (in req)
exit(req)
try_close(req) refs=0 -> true
[timeout 返回 true]
enter(req) 出现 4 次,Noisy dropped 在 req 里。
new_span在[timeout 开始]之前:info_span!在调用.instrument(..)时就求值了,instrument本身不调任何回调。- 第 1 次 poll:打
poll 1,yield_now返回Pending,退出。 - 第 2 次 poll:
yield_now完成,打poll 2,停在pending()上。 - 第 3 次 poll:10ms 后定时器触发,
Timeout被唤醒;它的poll先 poll 内层 future(仍然Pending,这一对enter/exit中间没有事件),再发现时间到了,返回超时错误。 - 第 4 次
enter不是 poll,是timeout返回后Instrumented被 drop:PinnedDrop先进入 span,再析构内层 future,async块里还活着的_n在这时析构,Noisy dropped因此在req里。然后exit,最后span字段析构,try_close返回true。 - 在 tracing 0.1.38 之前,第 4 组的
enter/exit不存在,Noisy dropped会在 span 外面(见上面的冷知识)。
小结
Instrument对所有类型实现;instrument(span)只是把 future 和 span 装进Instrumented,span 的父由调用instrument时的当前 span 决定;in_current_span是instrument(Span::current())。Instrumented::poll用一个只活一次 poll 的Entered:poll 前进入、返回时退出,守卫永远不跨.await。inner是ManuallyDrop<T>,PinnedDrop先进入 span 再手动析构内层,future 被中途丢掉时析构里的日志也在 span 里(tracing 0.1.38,#2562)。- 跨
.await持有enter()的守卫:同一线程上的下一个任务继承这个 span,新 span 认错父,无关的事件被挂错;多线程上还会在别的线程exit。 - clippy 0.1.99 没有
await_holding_span_guard;用await_holding_invalid_type并在clippy.toml里列出Entered和EnteredSpan。 WithDispatch在每次 poll 时set_default,解决的是「事件找哪个 subscriber」,只在用线程默认时需要;它和Instrumented各管一件事,可以叠加。