展开成什么
主线位置:span 主线的入口之一。
#[instrument]生成的代码里有一个普通的span!,它和手写的info_span!一样过四道门、调new_span;之后同步函数用enter返回的守卫进入 span,async fn用Instrumented在每次poll时进出。err/ret生成的事件走事件主线,在 参数 一章讲。
flowchart TB entry["instrument 入口"] --> precise["instrument_precise"] precise -->|"解析失败"| spec["instrument_speculative"] precise -->|"const fn"| cerr["compile_error"] precise --> detect["AsyncInfo::from_fn"] detect -->|"认出 async 块"| genasync["gen_async"] detect -->|"认不出"| genfn["gen_function"] spec --> genfn genasync --> genblock["gen_block"] genfn --> genblock genblock -->|"async 上下文"| apath["span 加 Instrumented"] genblock -->|"同步"| spath["级别门内 enter"]
#[instrument] 是一个过程宏,它看到的只是函数的 token:不知道参数类型实现了哪些 trait,也不知道返回的 future 是怎么来的。整个 expand.rs 都是在这个限制下按语法做决定。本页用 cargo expand(cargo-expand 1.0.104)把四种函数展开,对照源码看每一处为什么这样生成。实验 crate 的依赖:
tracing = "=0.1.44"
tracing-subscriber = { version = "=0.3.23", default-features = false, features = ["fmt", "registry", "std"] }
tokio = { version = "=1.54.1", features = ["full"] }
async-trait = "0.1" # 只有 async-trait 那一节用,解析到 0.1.92
本页的程序都在 M2 Max、rustc 1.99.0 上以 --release 运行。
入口:先精确解析,失败再退一步
instrument 先解析参数,再把函数交给两条路之一:
pub fn instrument(
args: proc_macro::TokenStream,
item: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
let args = syn::parse_macro_input!(args as attr::InstrumentArgs);
// Cloning a `TokenStream` is cheap since it's reference counted internally.
instrument_precise(args.clone(), item.clone())
.unwrap_or_else(|_err| instrument_speculative(args, item))
}
instrument_precise 把整个函数解析成 syn 的 ItemFn,函数体里的每条语句都要能解析。它做三件事:const fn 直接生成 compile_error!("the #[instrument]attribute may not be used withconst fns");用 AsyncInfo::from_fn 检查函数体是不是「返回一个 future」的样子(后面专门讲);都不是就交给 gen_function。
函数体有语法错误时 ItemFn 解析失败,走 instrument_speculative。它用 MaybeItemFn 只解析签名,函数体原样保留成一段 TokenStream,照常包上 span。这样语法错误由 rustc 在原来的位置报出来,而不是变成宏的一个笼统错误。这条路不做 async-trait 检测,因为函数体没被解析。
span 的名字在这里就定了:instrumented_function_name = input.sig.ident.to_string(),即函数名;没写 name = ... 时它就是 span 名。级别默认 INFO,target 默认 module_path!(),这两个默认值在 attr.rs 的 level 和 target 里。
同步函数:级别门里建 span 并 enter
#[derive(Debug)]
struct Config {
retries: u8,
}
#[instrument]
fn handle(id: u64, name: &str, cfg: &Config) -> usize {
name.len() + cfg.retries as usize + id as usize
}
展开(span! 内部和 #[allow(...)] 列表从略,宏展开 一章逐行讲过):
fn handle(id: u64, name: &str, cfg: &Config) -> usize {
{}
#[allow(clippy::suspicious_else_formatting)]
{
let __tracing_attr_span;
let __tracing_attr_guard;
if ::tracing::Level::INFO <= ::tracing::level_filters::STATIC_MAX_LEVEL
&& ::tracing::Level::INFO <= ::tracing::level_filters::LevelFilter::current()
|| { false }
{
__tracing_attr_span = {
use ::tracing::__macro_support::Callsite as _;
static __CALLSITE: ::tracing::callsite::DefaultCallsite = {
// ... META:名字 "handle",target "sync_fn",INFO,字段 id、name、cfg
};
// ... 四道门,通过时:
::tracing::Span::new(
meta,
&{
// ...
meta.fields()
.value_set_all(
&[
(::tracing::__macro_support::Option::Some(
&id as &dyn ::tracing::field::Value,
)),
(::tracing::__macro_support::Option::Some(
&name as &dyn ::tracing::field::Value,
)),
(::tracing::__macro_support::Option::Some(
&::tracing::field::debug(&cfg)
as &dyn ::tracing::field::Value,
)),
],
)
},
)
// ... 否则 __disabled_span(__CALLSITE.metadata())
};
__tracing_attr_guard = __tracing_attr_span.enter();
}
#[warn(clippy::suspicious_else_formatting)]
{
#[allow(
// ...
)]
if false {
let __tracing_attr_fake_return: usize = loop {};
return __tracing_attr_fake_return;
}
{ name.len() + cfg.retries as usize + id as usize }
}
}
}
从上往下看:
- 第一行的
{}是warnings()的输出。参数里有认不出的 token 时,这里会放一个带#[deprecated]的假常量来产生编译警告;没有就是空块。 __tracing_attr_span和__tracing_attr_guard先声明、不初始化,只在if成立时赋值。这段代码来自gen_block的 第 363 行起,注释说明了用意:rustc 会给延迟初始化的变量生成 drop 标志,级别被静态关掉时if条件是常量false,LLVM 能把 span、守卫和 drop 一起删干净。if条件是level_enabled!(INFO) || if_log_enabled!(INFO, {true} else {false})展开的结果:前两道门(STATIC_MAX_LEVEL和LevelFilter::current()),见 级别过滤的几道门。没开 tracing 的logfeature 时后半截就是{ false }。通过之后span!自己还会再查一遍这两道门,再加上Interest和enabled。- 守卫
__tracing_attr_guard活到函数结束。函数体在它之后执行,所以函数里的事件都在 span 里;函数返回时守卫先 drop(exit),span 再 drop(try_close)。 if false { ... return __tracing_attr_fake_return; }是gen_function加的「假返回」(第 71 行)。它永远不执行,作用是让编译器先按签名上的返回类型推断函数体;返回类型里的impl Trait会被erase_impl_trait换成_,因为let的类型标注里不能写impl Trait。
用一个打印每个回调的 Layer 看 subscriber 收到什么。Print 在 on_new_span 里把字段拼出来,在 on_event 里查当前 span 的名字:
use std::fmt::Write;
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id};
use tracing::{Event, Subscriber, info, instrument};
use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
use tracing_subscriber::registry::{LookupSpan, Registry};
// 把字段拼成 "a=1 b=\"x\""
struct Fields(String);
impl Visit for Fields {
fn record_debug(&mut self, f: &Field, v: &dyn std::fmt::Debug) {
let _ = write!(self.0, " {}={:?}", f.name(), v);
}
}
struct Print;
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for Print {
fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, _: Context<'_, S>) {
let mut f = Fields(String::new());
attrs.record(&mut f);
println!("new_span {} {:?}{}", attrs.metadata().name(), id, f.0);
}
fn on_enter(&self, id: &Id, _: Context<'_, S>) {
println!("enter {:?}", id);
}
fn on_exit(&self, id: &Id, _: Context<'_, S>) {
println!("exit {:?}", id);
}
fn on_close(&self, id: Id, _: Context<'_, S>) {
println!("close {:?}", id);
}
fn on_event(&self, ev: &Event<'_>, cx: Context<'_, S>) {
let mut f = Fields(String::new());
ev.record(&mut f);
let cur = cx.lookup_current().map(|s| s.name()).unwrap_or("-");
println!("event in {}:{}", cur, f.0);
}
}
#[derive(Debug)]
#[allow(dead_code)]
struct User {
name: String,
}
#[instrument]
fn sync_work(id: u64, user: &User) {
info!("working");
}
#[instrument]
async fn async_work(id: u64) {
info!("before yield");
tokio::task::yield_now().await;
info!("after yield");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing::subscriber::set_global_default(Registry::default().with(Print)).unwrap();
println!("-- sync");
sync_work(7, &User { name: "ann".into() });
println!("-- async, awaited");
async_work(8).await;
}
连跑 3 次输出相同:
-- sync
new_span sync_work Id(1) id=7 user=User { name: "ann" }
enter Id(1)
event in sync_work: message=working
exit Id(1)
close Id(1)
-- async, awaited
new_span async_work Id(2251799813685249) id=8
enter Id(2251799813685249)
event in async_work: message=before yield
exit Id(2251799813685249)
enter Id(2251799813685249)
event in async_work: message=after yield
exit Id(2251799813685249)
enter Id(2251799813685249)
exit Id(2251799813685249)
close Id(2251799813685249)
同步那一段就是上面的展开:建 span、进入、函数体里的事件、退出、关闭。span 名是函数名 sync_work,两个参数都成了字段。异步那一段留到后面。Id(2251799813685249) 是 Registry 复用了第一个 span 的槽,为什么是这个数见 Registry。
参数怎么变成字段:Value 还是 Debug
展开里 id 和 name 直接写成 &id as &dyn Value,cfg 却包了一层 ::tracing::field::debug(&cfg)。决定这一点的是 RecordType:
enum RecordType {
/// The field should be recorded using its `Value` implementation.
Value,
/// The field should be recorded using `tracing::field::debug()`.
Debug,
}
parse_from_ty 只看参数类型的语法:如果是一个路径,且路径的最后一段在 TYPES_FOR_VALUE 里(bool、str、各种整数和浮点、String、各种 NonZero*、Wrapping),就用 Value;如果是引用,剥掉一层引用再看;其他一律 Debug。
fn parse_from_ty(ty: &Type) -> Self {
match ty {
Type::Path(TypePath { path, .. })
if path
.segments
.iter()
.next_back()
.map(|path_segment| {
let ident = path_segment.ident.to_string();
Self::TYPES_FOR_VALUE.iter().any(|&t| t == ident)
})
.unwrap_or(false) =>
{
RecordType::Value
}
Type::Reference(syn::TypeReference { elem, .. }) => RecordType::parse_from_ty(elem),
_ => RecordType::Debug,
}
}
两种写法对 subscriber 的区别在 Visit 上:Value 让 u64 走 record_u64、&str 走 record_str,subscriber 拿到的是带类型的值(JSON 输出里是数字而不是字符串);debug(...) 一律走 record_debug。下面的 Which 只打印每个字段走了哪个方法:
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id};
use tracing::{Subscriber, instrument};
use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
use tracing_subscriber::registry::{LookupSpan, Registry};
// 只打印每个字段走的是哪个 record_* 方法
struct Which(Vec<String>);
impl Visit for Which {
fn record_u64(&mut self, f: &Field, v: u64) {
self.0.push(format!("{}: record_u64({v})", f.name()));
}
fn record_str(&mut self, f: &Field, v: &str) {
self.0.push(format!("{}: record_str({v:?})", f.name()));
}
fn record_debug(&mut self, f: &Field, v: &dyn std::fmt::Debug) {
self.0.push(format!("{}: record_debug({v:?})", f.name()));
}
}
struct Print;
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for Print {
fn on_new_span(&self, attrs: &Attributes<'_>, _: &Id, _: Context<'_, S>) {
let mut w = Which(Vec::new());
attrs.record(&mut w);
for line in w.0 {
println!("{line}");
}
}
}
type UserId = u64;
#[instrument]
fn lookup(a: u64, b: &str, c: String, d: UserId, e: Option<u64>, f: &&u64) {}
fn main() {
tracing::subscriber::set_global_default(Registry::default().with(Print)).unwrap();
lookup(1, "two", "three".into(), 4, Some(5), &&6);
}
连跑 3 次输出相同:
a: record_u64(1)
b: record_str("two")
c: record_str("three")
d: record_debug(4)
e: record_debug(Some(5))
f: record_u64(6)
d 的类型其实就是 u64,但宏只看见 UserId 这个名字,不在表里,于是走 Debug;f 是 &&u64,剥两层引用后命中 u64。
问: 为什么不干脆对所有参数都用 Value,实现了就按值记、没实现再退回 Debug?
答: 过程宏运行在类型检查之前,手里只有 token,没法问「这个类型实现了 Value 吗」;稳定版 Rust 也没有特化,写不出「实现了就用 A、否则用 B」的泛型代码。剩下的办法只有按名字猜,所以表是保守的:只放 tracing-core 为之实现了 Value 的类型(Option<T> 其实也实现了,但不在表里),猜不中的一律退回 Debug,代价只是字段从带类型的值变成了 Debug 文本。按名字猜也有猜错的时候:自己定义一个 #[derive(Debug)] struct Wrapping(u8); 当参数,它会被当成 std::num::Wrapping 按 Value 记,rustc 1.99.0 报 the trait bound `Wrapping: Value` is not satisfied,这时只能 skip 它或改用 fields(w = ?w)。
参数是怎么拆出名字的在 param_names:普通的 x: T 取 x;&x 这种引用模式剥掉 &;结构体和元组模式(Point { x, y }: Point)拆成里面的每个绑定,因为拿不到字段的具体类型,全部按 Debug 记。方法的 self(FnArg::Receiver)在 第 164 行 固定记成名为 self 的 Debug 字段,所以给方法加 #[instrument] 时,Self 要么实现 Debug,要么写 skip(self)。基本类型按值记录是 PR #1378 加的,tracing-attributes 0.1.16 起有,在那之前所有参数都按 Debug 记。
async fn:函数体变成交给 Instrument 的 future
#[instrument]
async fn fetch(id: u64) -> u64 {
id + 1
}
展开(span! 内部从略):
async fn fetch(id: u64) -> u64 {
{}
let __tracing_attr_span = {
// ... span!:名字 "fetch",字段 id,过四道门;
// 不通过时 __disabled_span(__CALLSITE.metadata())
};
let __tracing_instrument_future = async move {
#[allow(
// ...
)]
if false {
let __tracing_attr_fake_return: u64 = loop {};
return __tracing_attr_fake_return;
}
{ id + 1 }
};
if !__tracing_attr_span.is_disabled() {
::tracing::Instrument::instrument(
__tracing_instrument_future,
__tracing_attr_span,
)
.await
} else {
__tracing_instrument_future.await
}
}
它来自 gen_block 的 async_context 分支(第 305 行起),和同步版有三处不同:
- span 无条件创建,没有外面那层
level_enabled!。被过滤掉时拿到的是inner为None的空 span(Span::is_disabled为真),然后直接.await原来的 future,不包Instrumented。为什么不像同步版那样整个跳过,见下面「span 被关掉时」。 - 原函数体被搬进一个
async move块,存进__tracing_instrument_future。span 要先于这个块创建:块是move的,参数被移进去之后外面就不能再借用它们来记字段了。 - 不用
enter,而是用Instrument::instrument把 future 和 span 绑成Instrumented,每次poll进入、返回时退出,见 Instrument 与异步。
外层仍然是 async fn,所以上面这几行都写在 async fn 的函数体里,要等返回的 future 第一次被 poll 才执行。span 不是在调用 fetch(1) 时建的,而是在第一次 poll 时。
回头看前面实验的异步那一段:yield_now 让 future 返回一次 Pending,所以 span 被进入、退出了两次,两条事件分别落在两次 poll 里。最后多出来的一对 enter / exit 在 close 之前,来自 Instrumented 的 Drop:它在丢掉内部 future 前进入一次 span,让析构里的日志也在 span 里。
返回 future 的普通函数:AsyncInfo::from_fn
不是 async fn、但返回一个 future 的函数,如果照同步函数处理,span 只覆盖「创建 future」那一瞬间,future 真正执行时已经不在 span 里了。AsyncInfo::from_fn 在函数体里找三种写法,找到就改为给里面的 future 加 span。AsyncKind 的文档列出了它们:
/// The specific async code pattern that was detected
enum AsyncKind<'a> {
/// Immediately-invoked async fn, as generated by `async-trait <= 0.1.43`:
/// `async fn foo<...>(...) {...}; Box::pin(foo<...>(...))`
Function(&'a ItemFn),
/// A function returning an async (move) block, optionally `Box::pin`-ed,
/// as generated by `async-trait >= 0.1.44`:
/// `Box::pin(async move { ... })`
Async {
async_expr: &'a ExprAsync,
pinned_box: bool,
},
}
from_fn 的判断顺序:
| 步骤 | 条件 | 结果 |
|---|---|---|
| 1 | 函数本身是 async fn | 返回 None,按普通 async fn 处理 |
| 2 | 找函数体里最后一个表达式语句;没有 | None |
| 3 | 它是 async 块 | Async { pinned_box: false } |
| 4 | 它不是函数调用,或被调用的路径不以 Box::pin 结尾,或没有参数 | None |
| 5 | Box::pin 的参数是 async 块 | Async { pinned_box: true } |
| 6 | 参数是对某个函数的调用,且这个函数就在本函数体里声明为 async fn | Function,同时找名为 _self 的参数记下 Self 的类型 |
第 4 步比较的是字符串 path_to_string(path).ends_with("Box::pin"),Box::pin、std::boxed::Box::pin 都认。认出来以后 gen_async 只改写那一条语句,函数体里其余语句原样保留:Async 把块的内容交给 gen_block(..., true, ...),也就是上一节的异步分支,再包回 async move { }(和 Box::pin(...));Function 对里面那个 async fn 递归调用 gen_function。
两个手写的例子:
#[instrument]
fn fetch_later(id: u64) -> impl Future<Output = u64> {
async move { id + 1 }
}
#[instrument]
fn fetch_boxed(id: u64) -> Pin<Box<dyn Future<Output = u64> + Send>> {
Box::pin(async move { id + 1 })
}
展开(span! 内部从略):
fn fetch_later(id: u64) -> impl Future<Output = u64> {
async move {
let __tracing_attr_span = {
// ... span!:名字 "fetch_later",字段 id
};
let __tracing_instrument_future = async move { id + 1 };
if !__tracing_attr_span.is_disabled() {
::tracing::Instrument::instrument(
__tracing_instrument_future,
__tracing_attr_span,
)
.await
} else {
__tracing_instrument_future.await
}
}
}
fn fetch_boxed(id: u64) -> Pin<Box<dyn Future<Output = u64> + Send>> {
::std::boxed::Box::pin(async move {
let __tracing_attr_span = {
// ... span!:名字 "fetch_boxed",字段 id
};
let __tracing_instrument_future = async move { id + 1 };
// ... 同上
})
}
和 async fn 的展开几乎一样,span 都建在外层 async move 块里。区别是这里没有假返回:gen_async 直接调 gen_block,不经过 gen_function。span 名仍然是外层函数的名字,参数列表也取自外层函数的签名(&self.input.sig.inputs)。
async-trait
async-trait 把 trait 里的 async fn 改写成返回 Pin<Box<dyn Future>> 的普通函数。写在 impl 上的 #[async_trait] 先展开,方法上的 #[instrument] 留在改写后的函数上,再由它展开,所以它看到的正是第 5 步的 Box::pin(async move {})。用 async-trait 0.1.92:
#[async_trait]
trait Store {
async fn get(&self, key: u32) -> u32;
}
#[derive(Debug)]
struct Mem;
#[async_trait]
impl Store for Mem {
#[instrument]
async fn get(&self, key: u32) -> u32 {
key * 2
}
}
展开(async-trait 生成的 #[allow(...)] 和 span! 内部从略):
impl Store for Mem {
// ...
fn get<'life0, 'async_trait>(
&'life0 self,
key: u32,
) -> ::core::pin::Pin<
Box<
dyn ::core::future::Future<
Output = u32,
> + ::core::marker::Send + 'async_trait,
>,
>
where
'life0: 'async_trait,
Self: 'async_trait,
{
::std::boxed::Box::pin(async move {
let __tracing_attr_span = {
// ... span!:名字 "get",字段 self、key;
// self 是 ::tracing::field::debug(&self),key 直接按值
};
let __tracing_instrument_future = async move {
if let ::core::option::Option::Some(__ret) = ::core::option::Option::None::<
u32,
> {
#[allow(unreachable_code)] return __ret;
}
let __self = self;
let key = key;
let __ret: u32 = { key * 2 };
#[allow(unreachable_code)] __ret
};
if !__tracing_attr_span.is_disabled() {
::tracing::Instrument::instrument(
__tracing_instrument_future,
__tracing_attr_span,
)
.await
} else {
__tracing_instrument_future.await
}
})
}
}
Box::pin(async move { ... }) 里面的内容被换成了异步分支的那几行,原来的块(async-trait 自己的 __self、__ret 那些)成了 __tracing_instrument_future。外层函数仍是 &'life0 self,所以 self 照常记成 Debug 字段,Mem 要实现 Debug。
第 6 步的 Function 是给 async-trait 0.1.43 及更早版本留的:那时它生成的是「在函数体里声明一个 async fn,再 Box::pin 调它」,并把 self 改名成 _self。gen_block 里那段「_self 改回 self」(第 179 行起)和 IdentAndTypesRenamer 都是为了让 skip(self)、fields(x = self.x) 在那种代码里照样能写。
认不出的写法
判断全凭语法,所以只要 future 不是最后一个表达式里「字面上的」async 块,就认不出来。两个只差一个 let 的函数,套上前面的 Print:
use std::future::Future;
// 最后一个表达式是 async 块:认得出
#[instrument]
fn direct(x: u64) -> impl Future<Output = u64> {
async move {
info!("inside direct");
x
}
}
// async 块先绑到变量上再返回:认不出,按同步函数处理
#[instrument]
fn via_let(x: u64) -> impl Future<Output = u64> {
let fut = async move {
info!("inside via_let");
x
};
fut
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing::subscriber::set_global_default(Registry::default().with(Print)).unwrap();
println!("-- direct");
let f = direct(1);
println!("created");
f.await;
println!("-- via_let");
let f = via_let(2);
println!("created");
f.await;
}
连跑 3 次输出相同:
-- direct
created
new_span direct Id(1) x=1
enter Id(1)
event in direct: message=inside direct
exit Id(1)
enter Id(1)
exit Id(1)
close Id(1)
-- via_let
new_span via_let Id(2251799813685249) x=2
enter Id(2251799813685249)
exit Id(2251799813685249)
close Id(2251799813685249)
created
event in -: message=inside via_let
direct 的 span 在 created 之后才出现,因为它建在 async move 块里,第一次 poll 才执行。via_let 的最后一个表达式是路径 fut,第 3、4 步都不匹配,于是走 gen_function 的同步分支:调用时建 span、进入、退出、关闭,created 打印时 span 已经关了,future 执行时的事件不在任何 span 里(in -)。返回 std::future::ready(x) 之类函数调用的写法也一样。遇到这种情况,把 #[instrument] 去掉,自己对返回的 future 调 .instrument(span)。
PR #1866(「Correctly instrument fns returning an async move block」)之前,from_fn 只认 Box::pin(...) 的两种写法,fn f() -> impl Future { async move { ... } } 走同步分支,span 只包住「创建 future」那一下,也就是上面 via_let 的输出。PR 加了第 3 步「最后一个表达式是 async 块」,描述里说:「This should take care of half of #1863, meaning it should instrument functions returning an unboxed async move block.」另一半它也写明了:「This unfortunately does not cover generic impl Future returns, such as std::future::ready() and similar」。CHANGELOG 记在 tracing-attributes 0.1.19:「incorrect code generation for functions returning async blocks」。报告问题的 issue #1863 到 0.1.31 仍是打开的。
span 被关掉时
两条路径对「span 被过滤掉」的处理不同:
| 同步函数 | async fn / 认出的 async 块 | |
|---|---|---|
| 外层级别判断 | 有:level_enabled! 不通过就什么都不建 | 没有 |
span! | 不执行 | 执行,过四道门,返回空 span |
| 字段表达式 | 不求值 | 不求值(span! 内部的门挡住) |
| 函数体 | 照常执行,外面多一个块 | 照常执行,外面多一层 async move |
Instrumented | — | 不包,直接 .await |
字段表达式在两边都不求值,因为 ValueSet 只在 span! 的门都通过以后才构造(见 宏展开)。异步那边多出来的是:每次调用要跑一遍 span! 的判断、构造一个空 Span,并且 future 的状态里要放下这个 span 和内层的 async move 块。
用 criterion 0.5 量一下。四个函数:没加属性的同步函数和 async fn,以及加了 #[instrument] 的同样两个,函数体都是 x * 2;异步的用一个 noop waker 在栈上 poll 一次。三种情况各跑一个进程:不装 subscriber(none);装一个最多只要 WARN 的 subscriber,INFO 的 span 被第二道门挡掉(warn);装一个什么都要的 Registry,span 真的被创建(on):
use criterion::{Criterion, black_box};
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
use tracing::instrument;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
#[inline(never)]
fn plain(x: u64) -> u64 {
x * 2
}
#[instrument]
#[inline(never)]
fn traced(x: u64) -> u64 {
x * 2
}
async fn plain_async(x: u64) -> u64 {
x * 2
}
#[instrument]
async fn traced_async(x: u64) -> u64 {
x * 2
}
// 在栈上 pin 住 future,用 noop waker poll 一次
fn poll_once<F: Future>(f: F) -> F::Output {
let mut f = pin!(f);
match f.as_mut().poll(&mut Context::from_waker(Waker::noop())) {
Poll::Ready(v) => v,
Poll::Pending => unreachable!(),
}
}
fn main() {
let mode = std::env::var("MODE").unwrap_or_default();
match mode.as_str() {
"none" => {}
// 有 subscriber,但它说「最多 WARN」:INFO 的 span 被第二道门挡掉
"warn" => tracing::subscriber::set_global_default(Registry::default().with(LevelFilter::WARN)).unwrap(),
// 有 subscriber,INFO 的 span 真的被创建
"on" => tracing::subscriber::set_global_default(Registry::default()).unwrap(),
_ => panic!("MODE=none|warn|on"),
}
println!("mode={mode}");
println!(
"size_of future: plain_async={} traced_async={}",
std::mem::size_of_val(&plain_async(1)),
std::mem::size_of_val(&traced_async(1)),
);
let mut c = Criterion::default()
.without_plots()
.warm_up_time(std::time::Duration::from_millis(500))
.measurement_time(std::time::Duration::from_secs(2));
c.bench_function(&format!("{mode}/sync plain"), |b| b.iter(|| plain(black_box(3))));
c.bench_function(&format!("{mode}/sync traced"), |b| b.iter(|| traced(black_box(3))));
c.bench_function(&format!("{mode}/async plain"), |b| b.iter(|| poll_once(plain_async(black_box(3)))));
c.bench_function(&format!("{mode}/async traced"), |b| b.iter(|| poll_once(traced_async(black_box(3)))));
}
依赖多一行 criterion = "0.5"。其中一轮的输出(只留 time: 行,三个模式依次运行):
mode=none
size_of future: plain_async=16 traced_async=120
none/sync plain time: [906.25 ps 910.83 ps 917.27 ps]
none/sync traced time: [1.2846 ns 1.3111 ns 1.3382 ns]
none/async plain time: [344.19 ps 349.08 ps 355.97 ps]
none/async traced time: [6.0199 ns 6.0496 ns 6.0839 ns]
mode=warn
size_of future: plain_async=16 traced_async=120
warn/sync plain time: [917.63 ps 932.26 ps 952.60 ps]
warn/sync traced time: [1.2508 ns 1.2703 ns 1.2917 ns]
warn/async plain time: [339.97 ps 340.40 ps 340.84 ps]
warn/async traced time: [6.0900 ns 6.2274 ns 6.4101 ns]
mode=on
size_of future: plain_async=16 traced_async=120
on/sync plain time: [922.06 ps 955.63 ps 997.34 ps]
on/sync traced time: [45.421 ns 59.634 ns 85.364 ns]
on/async plain time: [340.51 ps 341.06 ps 341.67 ps]
on/async traced time: [54.781 ns 65.898 ns 92.191 ns]
一共跑了 3 轮。none 和 warn 两种模式下,三轮的中位数 sync traced 在 1.22–1.31 ns,async traced 在 6.03–6.99 ns,两个 plain 都在 1 ns 以内;on 模式的两个 traced 中位数在 54–69 ns 之间浮动、区间很宽,只能看量级。span 被关掉时,同步函数比不加属性多约 0.4 ns,是外层 level_enabled! 那次判断(读全局的 MAX_LEVEL 再比较);async fn 多约 5.7 ns,future 从 16 字节变成 120 字节。span 真被创建时,两边都是几十纳秒,这部分花在 Registry 分配槽、进出 span 栈和关闭上,见 Registry。
PR #1600(0.1 线上是回移的 #1605)的起因是:「Adding #[instrument(level = "debug")] attributes to functions in rustc caused a performance regression (in release, where debug! is fully optimized out) across all crates」。它把同步路径改成上面先声明、后在 level_enabled! 里赋值的写法,借 drop 标志让 LLVM 删掉整段。紧接着的 PR #1607 想让 async 路径也「totally skip constructing the span if the level is disabled」,做法是把 future 的代码在两个分支里各生成一次。两者一起发在 tracing-attributes 0.1.17。
结果是 issue #1615「attributes: compilation error in 0.1.17」。PR #1616 解释:函数体被引用了两次,返回的闭包「will technically have two distinct types based on whether or not the span is enabled」。它退回到现在的写法,并写明了原因:「for async functions, we can't have the optimization of not creating the span at all when the level is disabled, because we need to create the span before creating the future, as it may borrow arguments.」CHANGELOG 里 0.1.17 标着 YANKED,0.1.18「fixes issues introduced in v0.1.17」,同步展开里那对 suspicious_else_formatting 的 allow / warn 也是 0.1.18 为同一批改动补的(#1614,「suppress clippy::suspicious_else without nop let」)。
那么 async 路径的 span 到底在什么时候创建?用一个从没被 poll 的 future 就能看出来。
还是前面的 Print,M2 Max、rustc 1.99.0、--release:
#[instrument]
async fn work(id: u64) {
info!("running");
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing::subscriber::set_global_default(Registry::default().with(Print)).unwrap();
let a = work(1);
let b = work(2);
drop(a);
b.await;
}
问:输出里有几行 new_span,各带什么字段?Instrumented 的 Drop 会不会为 a 进入一次 span?
动脑筋答案
连跑 3 次输出相同:
new_span work Id(1) id=2
enter Id(1)
event in work: message=running
exit Id(1)
enter Id(1)
exit Id(1)
close Id(1)
只有一行 new_span,字段是 id=2。work(1) 返回的 future 从没被 poll,async fn 的函数体一行都没执行,__tracing_attr_span 根本没建,Instrumented 也还没被构造,所以丢掉它时 subscriber 什么也没收到。b 的那一组和前面实验的一样:一次 poll 就完成,加上 Instrumented 析构时的那对 enter / exit。Id(1) 也说明了这一点:b 是第一个真正分配槽的 span。
小结
instrument先把函数完整解析成ItemFn,解析失败退到只解析签名的MaybeItemFn,让函数体的语法错误由 rustc 原样报告;const fn直接报错。- span 名默认是函数名,级别
INFO,targetmodule_path!()。 - 同步函数:span 和守卫延迟初始化,只在
level_enabled!通过时创建并enter,守卫活到函数结束;静态关掉的级别整段被优化掉。 async fn:span 在第一次poll时无条件创建,函数体搬进async move块;span 有效就包成Instrumented,否则直接.await。关掉时仍比不加属性多几纳秒,future 也更大。- 参数按类型名决定记法:
TYPES_FOR_VALUE里的基本类型(剥掉引用后)按Value,其余、self和解构出的绑定都按Debug;类型别名认不出。 - 返回 future 的普通函数靠
AsyncInfo::from_fn按语法认:最后一个表达式是async块、Box::pin(async 块)、或Box::pin(本地 async fn 调用);async-trait 生成的就是第二种。其余写法(let后返回、future::ready)按同步函数处理,span 只包住创建 future 那一下。