从零写一个最小日志门面
主线位置:两条主线都自己走一遍。事件主线:宏在调用点放两个
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。
问: 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),但 targetdemo::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 ps | 436.93 ps | 371.00 ps |
门二:TRACE 高于 MAX_LEVEL | 320.06 ps | 316.99 ps | 321.55 ps |
门三:调用点缓存的 Never | 441.70 ps | 441.83 ps | 441.70 ps |
不缓存:每次问 enabled | 4.4532 ns | 4.5244 ns | 4.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,两个程序不同,只能比量级。
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,正在注册时别的线程拿临时的 sometimes | Callsite 与 Interest 缓存 |
| 调用点表与重建 | 没有,回答永不过期 | 无锁链表加 LOCKED_CALLSITES;Dispatch::new、rebuild_interest_cache 时整表重算并刷新 MAX_LEVEL | Callsite 与 Interest 缓存 |
| 多个 dispatcher | 只有一个全局的 | 线程局部默认、DefaultGuard、SCOPED_COUNT 快路径;多个 dispatcher 的回答用 Interest::and 合并 | Dispatch:全局与线程局部 |
| 按字段值过滤 | 没有 | event_enabled 在 event 之前、字段已求值后再问一次 | Subscriber trait |
| 带类型的字段 | 只有 &dyn Debug | Value 加 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合并都是为了去掉这些假定。