Rust in Everything
基于 tracing 0.1.44

从零写一个最小日志门面

主线位置:两条主线都自己走一遍。事件主线:宏在调用点放两个 static,先比全局最大级别,再读调用点缓存的一个字节,第一次经过时问 subscriber 并把答案写进去,都通过才把字段交给 subscriber 写一行;span 主线:span! 过同样的门,subscriber 发 Id,enter 把 Id 压进线程局部的栈,守卫 drop 时弹出,事件按这个栈打印 span 前缀。

flowchart TB
  callnode["event!(Level::Debug, ...)"] --> gate2{"级别不高于 MAX_LEVEL ?"}
  gate2 -->|否| stop1["停:一次原子读"]
  gate2 -->|是| byte{"调用点的字节"}
  byte -->|Never| stop2["停:再一次原子读"]
  byte -->|"0xFF 未知"| reg["register:问 subscriber"]
  reg --> byte
  byte -->|Sometimes| ask["每次问 enabled"]
  byte -->|Always| send["字段交给 subscriber"]
  ask -->|true| send
  send --> line["按线程局部栈拼一行"]

本页只讲思路和关键代码。mini 是一个 274 行的库(去掉空行和注释约 220 行)、一个演示程序、两个 criterion 基准,不依赖 tracing:

[dependencies]

[dev-dependencies]
criterion = "0.5"

完整实现、Layer 的组合、Registry 和每层过滤的从零实现,以及配套作业,放在付费课程里。

级别与元数据

#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
#[repr(u8)]
pub enum Level {
  Error = 1,
  Warn,
  Info,
  Debug,
  Trace,
}

pub struct Metadata {
  pub name: &'static str,
  pub target: &'static str,
  pub level: Level,
  pub fields: &'static [&'static str],
}

Level 从 1 开始,越详细越大,和 tracing 对外的比较方向一样;0 留给「关」,全局最大级别是一个 u8,0 就是 OFF。tracing 的 Level 内部数值是倒着排的(Trace = 0、Error = 4),为的是让 LevelFilter::OFF 落在 Option<Level> 的 niche 上,见 Metadata、Level 与 LevelFilter;mini 不需要 Option<Level>,直接用 0。

Metadata 的每个字段都是 'static,字段名是一个 &'static [&'static str],和 tracing 的 FieldSet 一样在编译期写死。只有这样,宏才能把它放进 static。

调用点:一个字节的缓存

#[repr(u8)]
pub enum Interest {
  Never = 0,
  Sometimes = 1,
  Always = 2,
}

const UNKNOWN: u8 = 0xFF; // 初值:还没问过

pub struct Callsite {
  meta: &'static Metadata,
  interest: AtomicU8,
}

impl Callsite {
  pub const fn new(meta: &'static Metadata) -> Self {
    Callsite { meta, interest: AtomicU8::new(UNKNOWN) }
  }

  #[inline]
  pub fn is_enabled(&'static self) -> bool {
    match self.interest.load(Relaxed) {
      NEVER => false,
      ALWAYS => true,
      SOMETIMES => dispatcher().is_some_and(|d| d.enabled(self.meta)),
      _ => self.register(), // UNKNOWN
    }
  }

  // 只在第一次经过时走到这里:问 subscriber,把答案写进字节
  #[cold]
  #[inline(never)]
  fn register(&'static self) -> bool {
    let Some(d) = dispatcher() else { return false };
    let interest = d.register_callsite(self.meta);
    self.interest.store(interest as u8, Relaxed);
    match interest {
      Interest::Never => false,
      Interest::Always => true,
      Interest::Sometimes => d.enabled(self.meta),
    }
  }
}

(NEVER、SOMETIMES、ALWAYS 是 Interest::Never as u8 等三个常量。)

和 tracing-core 的 DefaultCallsite 对一下:

  • 同样的热路径。 DefaultCallsite::interest 也是一次 Relaxed 读加一个 match,初值也是 0xFF,三个合法值都对不上就去注册。Relaxed 够用,因为这个字节只是缓存:读到旧值,最多是多问一次 subscriber。
  • 同样的冷路径标注。 register 标 #[cold] 和 #[inline(never)],和 DefaultCallsite::register 一样,让编译器把注册代码挪出每一个宏展开的地方。
  • 少了注册状态。 tracing 还有第二个字节 registration,用 CAS 保证只有一个线程注册,同时到达的线程拿一个临时的 sometimes。mini 没有它:两个线程同时第一次经过,register_callsite 可能被调用两次。对 mini 无害,因为同一个 subscriber 对同一份元数据总给同一个回答;tracing 不能这样省,它还要把调用点挂进全局表,挂两次会让链表成环(Callsite 与 Interest 缓存)。

register 第一行有一个细节:还没有 dispatcher 时返回 false,不写缓存。如果写下 Never,这个调用点以后永远不会再问,装上 subscriber 也没用。tracing 0.1 正有这样一个没修的问题:只有一个 dispatcher、而它只是某个线程的局部默认时,别的线程会给调用点缓存上 never(#3611,见 Callsite 与 Interest 缓存 的动脑筋)。

宏:两个 static 加一串 &&

#[macro_export]
macro_rules! event {
  ($lvl:expr, $msg:literal $(, $k:ident = $v:expr)* $(,)?) => {{
    static META: $crate::Metadata = $crate::Metadata {
      name: concat!("event ", file!(), ":", line!()),
      target: module_path!(),
      level: $lvl,
      fields: &["message" $(, stringify!($k))*],
    };
    static CALLSITE: $crate::Callsite = $crate::Callsite::new(&META);
    if ($lvl as u8) <= $crate::max_level() && CALLSITE.is_enabled() {
      let values: &[&dyn ::std::fmt::Debug] = &[&format_args!($msg) $(, &$v)*];
      $crate::__dispatch_event(&META, values);
    }
  }};
}

span! 的写法相同,name 是用户写的名字,fields 不带 message,if 的两个分支返回 Span::__new(&META, values) 和 Span::none()。

对照 宏展开 里 tracing 的展开:

  • 事件的名字是 "event 文件:行",消息是排在第一位、名叫 message 的字段,都照搬。
  • && 的顺序就是门的顺序:先比全局级别(tracing 的门二,level_enabled!),再读调用点(门三),Sometimes 时问 enabled(门四,tracing 的 __is_enabled)。被级别挡住时,CALLSITE.is_enabled() 根本不调用,调用点不注册。
  • 字段表达式和 format_args! 都写在 if 里面:门不通过,一个字段都不求值(tracing 的实测见 级别过滤的几道门)。
  • 值是栈上的一个 &[&dyn Debug],不分配。tracing 用 Option<&dyn Value>,subscriber 可以通过 Visit 按类型取值(字段);mini 只有 Debug 一种。
  • 没有门一:tracing 的 STATIC_MAX_LEVEL 来自 max_level_* feature,mini 没有 feature。

只能设一次的全局 dispatcher

pub trait Subscriber: Send + Sync {
  fn register_callsite(&self, meta: &'static Metadata) -> Interest {
    if self.enabled(meta) { Interest::Always } else { Interest::Never }
  }
  fn enabled(&self, meta: &Metadata) -> bool;
  fn max_level(&self) -> Level;
  fn event(&self, meta: &Metadata, values: &[&dyn Debug]);
  fn new_span(&self, meta: &Metadata, values: &[&dyn Debug]) -> u64;
  fn enter(&self, id: u64);
  fn exit(&self, id: u64);
}

static GLOBAL: OnceLock<Box<dyn Subscriber>> = OnceLock::new();
// 0 表示 OFF:还没装 subscriber 时,所有宏都停在这里
static MAX_LEVEL: AtomicU8 = AtomicU8::new(0);

pub fn set_global_default(sub: impl Subscriber + 'static) -> Result<(), &'static str> {
  let level = sub.max_level();
  GLOBAL.set(Box::new(sub)).map_err(|_| "a global default dispatcher has already been set")?;
  MAX_LEVEL.store(level as u8, Relaxed);
  Ok(())
}
  • register_callsite 的默认实现和 tracing-core 的一样:问一次 enabled,冻结成 Always / Never。
  • OnceLock::set 只有第一次成功,相当于 tracing-core 里 GLOBAL_INIT 的那次 compare_exchange(set_global_default)。设好以后 GLOBAL.get() 返回的引用是 'static 的,tracing 是用 Arc::into_raw 把 subscriber 泄漏出去达到同样的效果(Dispatch:全局与线程局部)。
  • 先装 dispatcher,再写 MAX_LEVEL。顺序反过来,别的线程可能先看到新级别、过了门二,却找不到 dispatcher;那时 register 返回 false 且不写缓存,下次再来,不会出错,只是白跑一趟。
  • tracing 的全局 MAX_LEVEL 也是初值 OFF、Relaxed 读;它由所有 dispatcher 的 max_level_hint 取最大值算出,在 Dispatch::new 和 rebuild_interest_cache 时重算。mini 只有一个 dispatcher,直接用它的 max_level。
📝NOTE
没有蠢问题

问: tracing-core 有一张全局调用点表 CALLSITES,新建 Dispatch 或 rebuild_interest_cache 时逐个调用点重算。mini 没有这张表,缓存在调用点里的回答不会过期吗?

答: 在 mini 里不会,靠的是两件事同时成立。第一,装 subscriber 之前 MAX_LEVEL 是 0,所有宏停在门二,没有一个调用点注册过,缓存里全是 0xFF;第二,subscriber 只能设一次,装上之后它对每个调用点的回答不再变化。缓存从第一次写下起就一直是对的,没有什么需要重算。

两个条件少一个,就需要那张表。tracing 允许多个 dispatcher 并存(每个线程一个局部默认),新建一个 Dispatch 就可能改变调用点的合并结果;reload 允许运行时换过滤器,换完旧答案就过期了(reload)。所以 tracing 把每个注册过的调用点挂进链表,rebuild_interest_cache 沿着它重新问一遍(Callsite 与 Interest 缓存)。

span 与线程局部的栈

pub struct Span {
  id: Option<u64>,
}

pub struct Entered<'a> {
  span: &'a Span,
}

impl Span {
  pub fn enter(&self) -> Entered<'_> {
    if let (Some(id), Some(d)) = (self.id, dispatcher()) {
      d.enter(id);
    }
    Entered { span: self }
  }
}

impl Drop for Entered<'_> {
  fn drop(&mut self) {
    if let (Some(id), Some(d)) = (self.span.id, dispatcher()) {
      d.exit(id);
    }
  }
}

Span 里只有一个 Id,被过滤掉的 span 是 None,对它的操作都是空操作。这和 tracing 的 Span 的思路一样:span 的名字、字段、「当前在哪个 span 里」都不在门面里,在 subscriber 里(Span 与 Entered)。tracing 的 Inner 还存着创建它的那份 Dispatch,mini 只有一个全局 dispatcher,不用存。

stderr subscriber 里和 span 有关的部分:

pub struct Stderr {
  pub default: Level,
  pub prefix: &'static str,
  pub prefix_level: Level,
  spans: Mutex<Vec<String>>, // 下标 + 1 就是 span 的 id;从不释放
}

thread_local! {
  // 当前线程进入了哪些 span:只有 subscriber 知道
  static STACK: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
}

impl Subscriber for Stderr {
  fn enabled(&self, meta: &Metadata) -> bool {
    meta.level <= self.default || (meta.level <= self.prefix_level && meta.target.starts_with(self.prefix))
  }

  fn max_level(&self) -> Level {
    self.default.max(self.prefix_level)
  }

  fn new_span(&self, meta: &Metadata, values: &[&dyn Debug]) -> u64 {
    // 把 name{k=v ...} 格式化好存起来,返回下标 + 1
    // ...
  }

  fn enter(&self, id: u64) {
    STACK.with(|s| s.borrow_mut().push(id));
  }

  fn exit(&self, id: u64) {
    STACK.with(|s| {
      let mut s = s.borrow_mut();
      if let Some(pos) = s.iter().rposition(|x| *x == id) {
        s.remove(pos);
      }
    });
  }

  // event:从栈底到栈顶,把每个 span 的文本拼在行首,再拼 target、message、其余字段,
  // 一次 write_all 写到 stderr
  // ...
}
  • 过滤规则相当于 RUST_LOG="<default>,<prefix>=<prefix_level>":默认级别以内都要,target 以 prefix 开头的放宽到 prefix_level。它只看元数据,所以默认的 register_callsite 就能把它缓存成 Always / Never。
  • span 的字段在 new_span 时格式化一次,存起来,事件只是拼接。tracing-subscriber 的 fmt::Layer 也是在 on_new_span 时把字段格式化好存进 Extensions(fmt)。
  • 当前 span 栈是一个线程局部的 Vec。exit 从栈顶往下找这个 Id,和 tracing-subscriber 的 SpanStack::pop 一样,找不到就什么也不做。tracing-subscriber 的 Registry 用的是 ThreadLocal<RefCell<SpanStack>>,每个 Registry 实例一份;mini 用 std 的 thread_local!,整个进程一份,能这样做只是因为全局只有一个 subscriber(Registry)。
  • span 的文本存进 Vec 后从不释放,Id 也就从不复用。tracing 靠 clone_span / try_close 计数,Registry 在计数归零后复用槽,复用时代数加一(Registry)。

跑一下

演示程序装一个相当于 RUST_LOG="info,demo::app=debug" 的 subscriber,在一个 request span 里打几条不同级别、不同 target 的事件,循环两遍,每遍再从另一个线程打一条:

use exp_tracing_handson::{Level, Stderr, event, set_global_default, span};

mod app {
  pub mod db {
    use exp_tracing_handson::{Level, event};
    pub fn query(id: u32) {
      event!(Level::Debug, "query", id = id);
    }
  }
}

mod noisy {
  use exp_tracing_handson::{Level, event};
  pub fn tick() {
    event!(Level::Debug, "tick");
  }
}

fn main() {
  event!(Level::Error, "before init");
  eprintln!("init");
  // 相当于 RUST_LOG="info,demo::app=debug"
  let first = set_global_default(Stderr::new(Level::Info, "demo::app", Level::Debug));
  let second = set_global_default(Stderr::new(Level::Trace, "", Level::Trace));
  eprintln!("first = {first:?}, second = {second:?}");

  for i in 0..2 {
    eprintln!("pass {i}");
    let req = span!(Level::Info, "request", id = i);
    let _g = req.enter();
    event!(Level::Info, "start", user = "ann");
    app::db::query(i);
    noisy::tick();
    event!(Level::Trace, "too verbose");
    std::thread::spawn(|| event!(Level::Info, "from another thread"))
      .join()
      .unwrap();
  }
  let passes = 2;
  event!(Level::Warn, "done after {passes} passes");
}

Stderr::register_callsite 每被调用一次打印一行 [register_callsite]。M2 Max、rustc 1.99.0、--release,跑 3 次输出逐字相同:

init
first = Ok(()), second = Err("a global default dispatcher has already been set")
pass 0
  [register_callsite] request (demo) -> Always
  [register_callsite] event src/bin/demo.rs:31 (demo) -> Always
 INFO request{id=0}: demo: start user="ann"
  [register_callsite] event src/bin/demo.rs:7 (demo::app::db) -> Always
DEBUG request{id=0}: demo::app::db: query id=0
  [register_callsite] event src/bin/demo.rs:15 (demo::noisy) -> Never
  [register_callsite] event src/bin/demo.rs:35 (demo) -> Always
 INFO demo: from another thread
pass 1
 INFO request{id=1}: demo: start user="ann"
DEBUG request{id=1}: demo::app::db: query id=1
 INFO demo: from another thread
  [register_callsite] event src/bin/demo.rs:40 (demo) -> Always
 WARN demo: done after 2 passes
  • before init 没有任何痕迹:那时 MAX_LEVEL 是 0,ERROR 也过不了门二,调用点没注册。装上 subscriber 之后也不会补问它。
  • 第二次 set_global_default 失败,第一个 subscriber 留着。
  • 每个调用点只注册一次:第 0 遍出现五行 [register_callsite],第 1 遍一行也没有,回答都从调用点的字节里读。
  • noisy::tick 被缓存成 Never:DEBUG 过得了门二(max_level 是 Debug),但 target demo::noisy 不以 demo::app 开头。第 1 遍它停在门三,只读一个字节。
  • too verbose 从没注册:TRACE 比 MAX_LEVEL 详细,停在门二,和 一条日志穿过 tracing 里那条 debug! 一样。
  • 另一个线程的事件没有 span 前缀:它的 STACK 是新的一份,空的。span 只在进入它的线程上「当前」;要带到别的线程,tracing 的做法是把 span 交给 Instrumented,在那边被 poll 时再进入(Instrument 与异步)。
  • 消息支持内联捕获:"done after {passes} passes" 交给 format_args!,passes 在调用处的作用域里解析。

缓存省下多少

用 criterion 量一条被 subscriber 拒绝的事件:subscriber 相当于 RUST_LOG="info,app=debug",所以 max_level 是 Debug,debug! 过得了门二;基准程序的 target 是 disabled,不以 app 开头,enabled 返回 false。对照组 event_nocache! 写在基准文件里,和 event! 只差一处:不要调用点,每次都问 subscriber:

/// 对照组:同样的门,但不缓存 Interest,每次都问 subscriber。
macro_rules! event_nocache {
  ($lvl:expr, $msg:literal $(, $k:ident = $v:expr)* $(,)?) => {{
    static META: exp_tracing_handson::Metadata = exp_tracing_handson::Metadata {
      // ... 和 event! 相同
    };
    if ($lvl as u8) <= exp_tracing_handson::max_level() && exp_tracing_handson::dispatcher().is_some_and(|d| d.enabled(&META)) {
      let values: &[&dyn ::std::fmt::Debug] = &[&format_args!($msg) $(, &$v)*];
      exp_tracing_handson::__dispatch_event(&META, values);
    }
  }};
}

// 相当于 RUST_LOG="info,app=debug":这个 bench 的 target 是 "disabled",
// 所以 debug! 过得了全局级别门,却被 subscriber 拒绝
fn disabled(c: &mut Criterion) {
  set_global_default(Stderr::new(Level::Info, "app", Level::Debug)).unwrap();
  c.bench_function("baseline", |b| b.iter(|| black_box(1u64)));
  c.bench_function("trace_level_gate", |b| {
    b.iter(|| event!(Level::Trace, "msg", v = black_box(1u64)))
  });
  c.bench_function("debug_cached_never", |b| {
    b.iter(|| event!(Level::Debug, "msg", v = black_box(1u64)))
  });
  c.bench_function("debug_nocache_enabled", |b| {
    b.iter(|| event_nocache!(Level::Debug, "msg", v = black_box(1u64)))
  });
}

cargo bench --bench disabled -- --noplot --warm-up-time 1 --measurement-time 3,M2 Max、rustc 1.99.0。第一轮的原始输出(只留 time: 行和 subscriber 打印的那一行):

baseline                time:   [440.30 ps 472.03 ps 506.01 ps]
trace_level_gate        time:   [317.19 ps 320.06 ps 323.45 ps]
  [register_callsite] event benches/disabled.rs:31 (disabled) -> Never
debug_cached_never      time:   [440.87 ps 441.70 ps 442.55 ps]
debug_nocache_enabled   time:   [4.4371 ns 4.4532 ns 4.4723 ns]

三轮的中间估计值:

停在第 1 轮第 2 轮第 3 轮
baseline(只有 black_box(1u64))472.03 ps436.93 ps371.00 ps
门二:TRACE 高于 MAX_LEVEL320.06 ps316.99 ps321.55 ps
门三:调用点缓存的 Never441.70 ps441.83 ps441.70 ps
不缓存:每次问 enabled4.4532 ns4.5244 ns4.4606 ns

除了 baseline,三轮相差不到 2%。

  • 门二到门三多约 0.12 ns:多读一个字节、多一次比较。注册只发生一次,[register_callsite] 在三个基准里只出现一次,也就是 debug_cached_never 的预热里。
  • 不缓存约 4.5 ns,是有缓存的 10 倍:每次都要 OnceLock::get 找 dispatcher、通过 dyn Subscriber 虚调用 enabled,在里面比两次级别、做一次 target 前缀比较。这个 enabled 已经很便宜了,真实的过滤器(比如 EnvFilter 要在指令表里找最具体的一条)只会更贵。
  • 这正是调用点缓存的价值:subscriber 对一个调用点的意见如果只取决于元数据,就只问一次,之后每次经过只付一次原子读。tracing 自己的数字在 级别过滤的几道门:门三约 0.6 ns,sometimes 时问 enabled 约 2.4 ns,两个程序不同,只能比量级。
❗IMPORTANT
动脑筋:subscriber 的 max_level 降到 INFO,后两行变成多少?

同一个基准,只把 subscriber 换成 Stderr::new(Level::Info, "app", Level::Info),也就是 RUST_LOG="info",平台 M2 Max、rustc 1.99.0。debug_cached_never 和 debug_nocache_enabled 各是多少?[register_callsite] 那一行还会出现吗?

和 tracing 比:少了什么

mini 能把两条主线跑通,离 tracing 还差很远。下表每一行是 mini 缺的一样东西:

缺的mini 现在怎样tracing 怎么做见
编译期的门没有STATIC_MAX_LEVEL 由 max_level_* / release_max_level_* feature 决定,挡住的级别连代码带元数据被删掉级别过滤的几道门
注册状态并发第一次经过可能问两次第二个原子字节 + CAS,正在注册时别的线程拿临时的 sometimesCallsite 与 Interest 缓存
调用点表与重建没有,回答永不过期无锁链表加 LOCKED_CALLSITES;Dispatch::new、rebuild_interest_cache 时整表重算并刷新 MAX_LEVELCallsite 与 Interest 缓存
多个 dispatcher只有一个全局的线程局部默认、DefaultGuard、SCOPED_COUNT 快路径;多个 dispatcher 的回答用 Interest::and 合并Dispatch:全局与线程局部
按字段值过滤没有event_enabled 在 event 之前、字段已求值后再问一次Subscriber trait
带类型的字段只有 &dyn DebugValue 加 Visit,按 i64、str、错误等类型分派字段:Field、Value 与 Visit
span 的父子与关闭没有父 span,数据从不释放Attributes 带显式 / 当前 / 根三种父;clone_span / try_close 计数,CloseGuard 保证所有层看完再释放span 与 Event 的数据、Registry
守卫只有借用的 Entered还有拥有 span、不是 Send 的 EnteredSpan;异步代码用 Instrumented 每次 poll 进出Span 与 Entered、Instrument 与异步
组合一个 subscriber 包办存储、过滤、输出Registry 存 span,Layer 各管一件事,Layered 叠起来;全局与每层过滤Layer 与 Layered、过滤
宏的写法级别、消息、k = v 一种target:、parent:、?x、%x、a.b = ..、只写名字的简写,以及 info! 等五个级别宏宏展开

前三行是缓存:mini 的缓存之所以简单,是因为它假定了「一个 subscriber、只设一次、回答不变」,tracing 的复杂度大半来自去掉这三个假定。中间几行是 subscriber 能拿到的信息。最后两行是用起来的样子,Layer 组合、Registry 和每层过滤的从零实现在付费课程里。

动脑筋答案

M2 Max、rustc 1.99.0,cargo bench --bench disabled_info,第一轮:

baseline                time:   [358.52 ps 373.44 ps 389.81 ps]
trace_level_gate        time:   [320.41 ps 321.72 ps 323.05 ps]
debug_cached_never      time:   [321.44 ps 322.82 ps 324.13 ps]
debug_nocache_enabled   time:   [320.93 ps 322.43 ps 323.71 ps]

跑了 3 次,后三行的中间估计值都在 312–325 ps 之间,三轮都没有 [register_callsite] 那一行。

  • 两行都掉到和 trace_level_gate 一样,约 0.32 ns:max_level 是 Info,MAX_LEVEL 是 3,DEBUG(4)在门二就停下,&& 短路,调用点的字节和 enabled 都碰不到。有没有缓存不再有区别。
  • 调用点从没注册,所以 [register_callsite] 不出现。
  • 这就是 tracing 加全局 MAX_LEVEL 的理由:能用级别一刀切的,门二一次原子读就够了,连调用点都不用碰。门三的缓存管的是门二挡不住的那部分,也就是「级别够、但 target 不对」的调用点(Metadata、Level 与 LevelFilter 的冷知识)。

小结

  • 调用点就是宏在每个调用位置放的一个 static:元数据加一个 AtomicU8。初值 0xFF 表示没问过,第一次经过时在 #[cold] 的 register 里问 subscriber,之后一次 Relaxed 读。
  • 宏展开成两个 static 加一串 &&:全局级别、调用点字节、Sometimes 时的 enabled;字段和 format_args! 在 if 里,门不通过不求值。
  • 全局 dispatcher 用 OnceLock 做只能设一次;装上之前 MAX_LEVEL 是 0,所有宏停在门二,所以 mini 不需要调用点表;没有 dispatcher 时不写缓存。
  • span 里只有 Id;名字、字段、当前 span 栈都在 subscriber 里,栈是线程局部的,所以另一个线程的事件不带 span 前缀。
  • 实测:被 subscriber 拒绝的 debug! 有缓存约 0.44 ns,每次问 enabled 约 4.5 ns;被级别挡住的约 0.32 ns,这时有没有缓存都一样。
  • mini 靠「一个 subscriber、只设一次、回答不变」三个假定保持简单;tracing 的注册状态、调用点表、重建、Interest 合并都是为了去掉这些假定。

💬 关联讨论

发起讨论
加载中...