取舍汇总
主线位置:两条主线的总账。前两条决定任务主线怎么量、量到哪里为止,第三条决定运行时主线能信到什么程度,后四条是这些决定在类型、API 和导出上的延伸。
flowchart TB d1[包 waker,不改调度器] --> c1["每次 poll 多 75 到 125ns,只量得到外层"] c1 --> f1["FutureMonitor 借外层的排队"] d2[写方只累加,读方做差] --> c2["最大空闲例外:swap 清零"] d3[运行时只做读者] --> c3["提交滞后、EWMA、current_thread 为 0"] d4[不稳定字段用 cfg 删掉] --> c4["换个 RUSTFLAGS 就编译不过"] d5["TaskMonitorCore 与 FutureMonitor"] --> c5["两套 builder、Stream 只给默认类型"] d6[导出口径写死在宏里] --> c6["均值成了 counter"] d7[与 console 分工] --> c7["只出数字,不出事件"]
格式和其他卷的同名页(如 tracing 卷)相同:做了什么决定,换来了什么,代价是什么,代价由谁来补。机制都在前面各页讲过,这里只给结论和链接;本页唯一的新实验是开销测量,其余数字都来自前面各页,平台都是 M2 Max(8 个性能核 + 4 个能效核)、macOS、rustc 1.99.0、--release。
包 waker,不改调度器
决定:任务指标不碰 tokio 的调度器。instrument 把 future 包进 Instrumented,每次 poll 在外面记时间;把调用方的 waker 存进 AtomicWaker,换成自己的 State 去 poll 内层,内层将来调 wake 时先记下唤醒时刻(Instrumented 与 instrument_poll、包装 waker)。
换来的:
- 不需要
tokio_unstable,不需要在Builder上开任何开关,也不绑定 tokio:任何能 poll future 的执行器上都能用。不开rtfeature 时,连时钟都换成std::time::Instant(怎么读这一卷)。 - 粒度由用户定:一个路由一个 monitor、一类后台任务一个 monitor,包哪个量哪个。运行时指标只能看到「所有任务」。
代价:
- 每次 poll 的固定开销。没有被唤醒的 poll 要取三次
Instant::now()、做一次AtomicWaker::register和若干SeqCst原子操作;被唤醒过的 poll 还要在wake里多取一次时间、多记空闲和排队。下面实测:单线程每次 poll 多 75ns 或 125ns。 - 所有包进同一个 monitor 的任务写同一组原子变量。多线程同时 poll 时这些缓存行在核之间来回传,开销随线程数上升:同一个 monitor、12 个线程同时 poll,每次 poll 约 3.3µs。
- 只量得到被包的那一层。排队时间只有最外层(被 tokio 直接调度的那个 future)量得准;嵌套的
Instrumented会多算外层先跑的部分;yield_now和预算耗尽走 tokio 的 defer 路径,被记成空闲而不是排队(包装 waker)。
谁来补:嵌套的排队由 0.5.2 的 FutureMonitor 补:它不自己量排队,而是读外层任务打开 publish_scheduling_delay 后放进线程局部变量的记录(TaskMonitorCore 与 FutureMonitor)。运行时级的排队延迟,tokio 1.54.1 自己有一个直方图(要 tokio_unstable 加 schedule-latency feature),0.5.2 没有接(Worker 差分与 poll 时间直方图)。共享原子变量的争用没有人补,见下面的实验。
实验:每次 poll 多花多少
上游有一个 bench poll_overhead:每个 CPU 一个线程,各自不停地 poll 一个「唤醒自己、返回 Pending」的 future。注意它用 iter::repeat((monitor, ...)) 把同一个 monitor 克隆给所有线程,量的是争用最严重的情况。本卷不运行上游代码,自己写一个,多拆出几种情形:
use std::future::Future;
use std::hint::black_box;
use std::pin::Pin;
use std::sync::{Arc, Barrier};
use std::task::{Context, Poll, Waker};
use std::time::{Duration, Instant};
use tokio_metrics::TaskMonitor;
// 和上游 bench 的 TestFuture 一样:每次 poll 都唤醒自己,返回 Pending
struct SelfWake;
impl Future for SelfWake {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
cx.waker().wake_by_ref();
Poll::Pending
}
}
// 不唤醒:只走 poll 的计时路径,没有空闲和排队
struct NoWake;
impl Future for NoWake {
type Output = ();
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<()> {
Poll::Pending
}
}
const ITERS: u64 = 5_000_000;
// 返回每次 poll 的平均纳秒数
fn poll_loop<F: Future>(f: F) -> f64 {
let mut cx = Context::from_waker(Waker::noop());
let mut f = Box::pin(f);
let t = Instant::now();
for _ in 0..ITERS {
let _ = black_box(f.as_mut().poll(&mut cx));
}
t.elapsed().as_nanos() as f64 / ITERS as f64
}
// mode 0:不包;1:所有线程共用一个 monitor;2:每个线程一个 monitor
fn run(threads: usize, mode: u8) -> f64 {
let shared = TaskMonitor::new();
let barrier = Arc::new(Barrier::new(threads));
let hs: Vec<_> = (0..threads)
.map(|_| {
let m = if mode == 1 { shared.clone() } else { TaskMonitor::new() };
let b = barrier.clone();
std::thread::spawn(move || {
b.wait();
if mode == 0 { poll_loop(SelfWake) } else { poll_loop(m.instrument(SelfWake)) }
})
})
.collect();
hs.into_iter().map(|h| h.join().unwrap()).sum::<f64>() / threads as f64
}
fn main() {
let raw = poll_loop(NoWake);
let inst = poll_loop(TaskMonitor::new().instrument(NoWake));
let t = Instant::now();
for _ in 0..ITERS {
black_box(tokio::time::Instant::now());
}
let now = t.elapsed().as_nanos() as f64 / ITERS as f64;
println!("1 thread, no wake: raw={raw:.1}ns instrumented={inst:.1}ns; one Instant::now()={now:.1}ns");
let n = std::thread::available_parallelism().unwrap().get();
for threads in [1, 4, n] {
let (raw, shared, own) = (run(threads, 0), run(threads, 1), run(threads, 2));
println!("threads={threads:>2} self wake: raw={raw:.1}ns shared_monitor={shared:.1}ns monitor_per_thread={own:.1}ns");
}
let _ = Duration::ZERO;
}
依赖同前(tokio-metrics = "=0.5.2"、tokio = { version = "=1.54.1", features = ["full"] }),每种情形 500 万次 poll,连跑 3 次。其中一次:
1 thread, no wake: raw=0.3ns instrumented=74.8ns; one Instant::now()=16.8ns
threads= 1 self wake: raw=0.3ns shared_monitor=124.2ns monitor_per_thread=124.3ns
threads= 4 self wake: raw=0.3ns shared_monitor=946.7ns monitor_per_thread=250.1ns
threads=12 self wake: raw=0.4ns shared_monitor=3346.5ns monitor_per_thread=437.3ns
三次的范围:不唤醒 74.8–80.8ns;单线程唤醒 124–174ns;4 线程共用一个 monitor 947–1151ns、各用各的 250–366ns;12 线程共用 3.2–3.3µs、各用各的 389–444ns。
- 单线程、不唤醒,每次 poll 多约 75ns,其中三次取时间约 3 × 17 = 51ns,剩下的是原子操作和
AtomicWaker::register。被包的 future 本身几乎不花时间(0.3ns),所以这就是纯开销。 - 唤醒自己时约 125ns:
State::on_wake再取一次时间(第 3049 行),下一次 poll 还要记空闲、排队、长短两桶。 - 共用一个 monitor,开销随线程数直线上升:4 线程约 1µs,12 线程约 3.3µs,每次 poll 都在抢同一组缓存行。各用各的 monitor 时只到几百纳秒;它也随线程数上升,原因没有细查(可能有相邻分配的伪共享、频率变化和 4 个能效核)。
要放到真实场景里看:一次「真正干活」的 poll 通常是几微秒到几百微秒,75–125ns 是百分之几以内;而上面的多线程情形是所有线程什么都不干、只在 poll,是争用的上界。真实服务里,一个热路由的 monitor 被所有 worker 同时写,介于两者之间。monitor 按路由拆开,本身就减少了争用。
写方只累加,读方做差
决定:RawMetrics 里 18 个 AtomicU64 只增不减(两个最大值用 fetch_max),写路径不清零、不加锁;「区间」完全由读的一方算:TaskIntervals 记住上一次的读数,逐字段相减(TaskMetrics 与派生指标、intervals:迭代器差分)。
换来的:
- 写路径简单:每个计数一次
fetch_add,没有「读者在清零时写者正好在加」的竞争。 - 读者互不干扰:
cumulative()随便调,intervals()开几个都各算各的差。 - 采样周期完全交给调用方,
intervals()只是一个Iterator,库里没有后台任务和定时器。
代价:
- 最大空闲是例外。最大值不能做差,只好用一个「区间内」的原子变量读完
swap(0)。这个变量一个 monitor 只有一份,同一个 monitor 开两个intervals(),后取样的一方读到 0(intervals:迭代器差分的实验)。 - 19 个字段逐个
load,不是同一时刻的快照,读到一半时写方可能刚加完一部分。 - 首次 poll 延迟从
instrument()被调用算起,「包了还没 spawn」的时间也在里面(Instrumented 与 instrument_poll)。
谁来补:调用方。一个 monitor 只开一个 intervals(),把结果分发出去;或者用 cumulative() 交给会算速率的系统。
运行时只做读者
决定:运行时指标不在 tokio 里装任何东西,RuntimeMonitor 只存一个 Handle::metrics() 句柄,每次 next() 读 tokio 的累计计数器做差(稳定与不稳定两层字段)。
换来的:零写入开销(计数 tokio 本来就在记),实现只有几百行,tokio 加了新指标,这边加一个字段就能接上。
代价:数字的口径完全由 tokio 决定,而 tokio 记这些数有的是为了调度,不是为了监控(Worker 差分与 poll 时间直方图):
- worker 的计数先记在线程私有的批次里,只在 park 前和每 61 个 tick 的维护时提交。一次 poll 忙 2.5 秒,前 2 秒的区间里忙碌时长是 0,最后一个区间一次补上,2 个 worker 的
busy_ratio到了 4.9。 mean_poll_duration是 tokio 为自动调节全局队列间隔算的 EWMA(α = 0.1),同一组 poll 换个顺序,结果从 11µs 变成 2ms,真实均值是 30µs;current_thread 调度器不算它,恒为 0。- 不稳定字段要
tokio_unstable,而 tokio 的 unstable API 不承诺 semver。
谁来补:tokio-metrics 没有补。对「某个 worker 被卡住」这类问题,任务指标的慢 poll 是更及时的信号;要按区间的 poll 时长分布,用 poll 时间直方图。
不稳定字段用 cfg 删掉,不用 Option
决定:define_runtime_metrics! 给 31 个不稳定字段逐个加 #[cfg(tokio_unstable)],不开 cfg 时字段不存在;同一个宏模式也用在派生指标和 metrics-rs 的字段表上(稳定与不稳定两层字段)。
换来的:
- 用错在编译期暴露。如果是
Option<u64>或填 0,不开 cfg 的程序会安静地导出一串 0,看板上看不出是「没开」还是「真的是 0」。 - 不开 cfg 时连 tokio 的这些方法都不调,
Worker快照里也不存它们的上次读数。
代价:
- 同一份代码,换一个
RUSTFLAGS就编译不过(E0609)。docs.rs 是带 cfg 构建的,文档上看得到的字段,自己的项目里不一定有(怎么读这一卷)。 - 字段的 cfg 会被某些派生宏弄丢:issue #128 里,派生宏把结构体重新展开、丢掉了字段上的 cfg,于是不开 cfg 时也要求字段的类型能解析。0.5.1(#131)的修法是让
PollTimeHistogram类型不再加 cfg,只留字段上的。 tokio_unstable是--cfg而不是 feature,要由构建最终程序的人在RUSTFLAGS或.cargo/config.toml里设,依赖它的库没法替用户打开。
谁来补:这是有意付的价,和 tokio 自己对 unstable API 的处理方式一致(tokio 卷源码地图)。
TaskMonitorCore 与 FutureMonitor:两个变体
决定:0.4.9(#115)把 TaskMonitor 拆成「Arc 包着的 TaskMonitorCore」,后者 const fn 构造、可以放进 static;0.5.2(#127)新增 FutureMonitor,一个 future 一份指标(TaskMonitorCore 与 FutureMonitor)。
换来的:各回答一个真实场景。static 的 monitor 不用 OnceLock、不用堆分配,State 里存的是引用,少一次引用计数;FutureMonitor 让指标可以挂到单个请求的日志或 trace 上,而不只是按路由聚合。
代价:
- 两套 builder(一个
&mut self、一个按值的const fn),intervals在TaskMonitorCore上是关联函数而不是方法。 Stream只给默认的Instrumented<T, TaskMonitor>实现了,static的 monitor 包出来的流不能当Stream用(Instrumented 与 instrument_poll);metrics-rs 的 reporter 也只收TaskMonitor(metrics-rs 与 metrique)。FutureMonitor的排队指标要外层任务的 monitor 先打开publish_scheduling_delay,每次 poll 多写一次线程局部变量;不打开时为 0,且不报错。
导出口径写死在宏里
决定:metrics-rs 集成每个区间调一次 next(),按 metric_refs! 宏调用里写死的类型,把字段交给 Counter(加差值)或 Gauge(设成差值)(metrics-rs 与 metrique)。
换来的:一行 spawn(builder.describe_and_run(monitor)) 就能接上任何 metrics-rs 导出器,名字、单位、说明都登记好了;加字段时测试会检查每个字段和派生指标都有导出。
代价:
- 口径不一致:任务的次数是本区间的 gauge,运行时 unstable 的次数是累计的 counter;8 个
mean_*和max_idle_duration被当成 counter,导出后是「每个区间的值之和」,实测 50 → 100 → 100。 - 比率登记为
Percent,值是 0 到 1;空区间导出 NaN。 - 直方图每个桶用起点代表整个桶。
谁来补:用户自己。需要准确口径的,不用这套集成,从 cumulative() 取值自己导出。
与 tokio-console 的分工
决定:README 的定位是「console 主要用于本地调试,本库把指标报给生产环境的工具」,并说长期可能与 console 合并;对 dial9 的说法是「本库告诉你出了问题,dial9 帮你找出是什么问题」(它解决什么问题)。
换来的:本库只出数字:一组计数和时长,按区间汇总,体积固定,适合长期跑在生产里接看板和告警。
代价:看不到单个任务的生命周期、看不到「哪一个任务」慢,也没有事件流可以事后回放;按 monitor 聚合之后,一个慢请求会被同一路由的其他请求平均掉(FutureMonitor 补了一部分)。
console 那边依赖 tokio 自己的 tracing 埋点。tokio 1.54.1 里这些埋点包在 cfg_trace! 里,条件是 tokio_unstable 加 tokio 的 tracing feature,所以 console 拿到的任务事件同样要 tokio_unstable。console-subscriber 自己文档里的要求,本卷没有核实。
小结
- 包 waker 换来了「不改调度器、不要 unstable、粒度由用户定」,代价是每次 poll 多 75–125ns、多线程共用一个 monitor 时到 1–3µs,以及排队只量得到外层。
- 写方只累加让写路径无锁、读者互不干扰,唯一的例外是区间最大空闲的
swap(0)。 - 运行时只做读者,零写入开销,但口径全看 tokio:计数提交滞后、
mean_poll_duration是 EWMA、current_thread 上为 0。 - 不稳定字段用 cfg 删掉,用错在编译期暴露,代价是换个
RUSTFLAGS就编译不过,还被派生宏坑过一次。 TaskMonitorCore与FutureMonitor各解决一个真实场景,代价是两套 builder 和不对称的 trait 实现。- metrics-rs 集成开箱即用,但均值和最大值被导出成 counter,口径要用户自己把关。