Rust in Everything
基于 tracing 0.1.44

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。

📝NOTE
冷知识:future 被丢掉时进入 span,是 2023 年才加的

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。

❗IMPORTANT
动脑筋: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 任务。

📝NOTE
没有蠢问题

问: 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 各管一件事,可以叠加。

💬 关联讨论

发起讨论
加载中...