TaskMetrics 与派生指标
主线位置:任务主线的终点。前两页的
instrument_poll和on_wake把数字累加进RawMetrics;本页讲读的一方怎么把这些原子变量变成用户看到的TaskMetrics,以及在它上面算出来的派生指标。
flowchart TB w1["instrument_with:包装数"] --> raw w2["instrument_poll:首次延迟、空闲、排队、快慢桶"] --> raw w3["PinnedDrop:丢弃数"] --> raw raw["RawMetrics:18 个 AtomicU64 + 2 个阈值"] --> m["metrics():逐个 load,快慢相加"] m --> c["cumulative():累计值"] m --> iv["intervals():与上次做差"] c --> tm["TaskMetrics:19 个字段"] iv --> tm tm --> d["10 个派生指标:mean_*、两个 ratio"]
RawMetrics:写的一方
RawMetrics 是每个 TaskMonitorCore 里的一份,去掉注释是这样:
struct RawMetrics {
slow_poll_threshold: Duration,
long_delay_threshold: Duration,
instrumented_count: AtomicU64,
first_poll_count: AtomicU64,
total_idled_count: AtomicU64,
total_scheduled_count: AtomicU64,
total_fast_poll_count: AtomicU64,
total_slow_poll_count: AtomicU64,
total_long_delay_count: AtomicU64,
total_short_delay_count: AtomicU64,
dropped_count: AtomicU64,
total_first_poll_delay_ns: AtomicU64,
total_idle_duration_ns: AtomicU64,
local_max_idle_duration_ns: AtomicU64,
global_max_idle_duration_ns: AtomicU64,
total_scheduled_duration_ns: AtomicU64,
total_fast_poll_duration_ns: AtomicU64,
total_slow_poll_duration: AtomicU64,
total_short_delay_duration_ns: AtomicU64,
total_long_delay_duration_ns: AtomicU64,
}
两个阈值在构造时定下,之后不变;其余 18 个原子变量只增不减(两个最大值用 fetch_max,其中一个读的时候会被清零,见后文)。时长都存成纳秒的 u64,只有 total_slow_poll_duration 漏了 _ns 后缀,存的同样是纳秒。
注意这里没有 poll 总次数和 poll 总时长。instrument_poll 只往快、慢两个桶里加,总数是读的时候算的。
metrics():读的一方
RawMetrics::metrics 把每个原子变量 load(SeqCst) 一遍,拼成一个 TaskMetrics:
fn metrics(&self) -> TaskMetrics {
let total_fast_poll_count = self.total_fast_poll_count.load(SeqCst);
let total_slow_poll_count = self.total_slow_poll_count.load(SeqCst);
let total_fast_poll_duration =
Duration::from_nanos(self.total_fast_poll_duration_ns.load(SeqCst));
let total_slow_poll_duration =
Duration::from_nanos(self.total_slow_poll_duration.load(SeqCst));
let total_poll_count = total_fast_poll_count.saturating_add(total_slow_poll_count);
let total_poll_duration = total_fast_poll_duration.saturating_add(total_slow_poll_duration);
TaskMetrics {
instrumented_count: self.instrumented_count.load(SeqCst),
// ...
max_idle_duration: Duration::from_nanos(self.global_max_idle_duration_ns.load(SeqCst)),
// ...
}
}
每个字段单独 load,19 个字段不是同一时刻的快照:读到一半时另一个线程完成了一次 poll,total_poll_count 可能已经加上而 total_idle_duration 还没有。每个原子操作都是 SeqCst,但这只保证单个变量的顺序一致,不保证 19 个变量之间的一致。对看板来说这点误差可以忽略;拿两个字段做精确的等式检查则不行。
TaskMonitor::cumulative() 直接返回这个结果;intervals() 在它上面做差,见 03 采样与导出的 intervals:迭代器差分。
19 个字段
TaskMetrics 带 #[non_exhaustive],字段全是 pub。按量的是哪一段时间分组:
| 组 | 字段 | 在哪里加 |
|---|---|---|
| 生命周期 | instrumented_count | instrument_with |
dropped_count | PinnedDrop | |
first_poll_count、total_first_poll_delay | 首次 poll | |
| 空闲 | total_idled_count、total_idle_duration、max_idle_duration | idled_at < woke_at |
| 排队 | total_scheduled_count、total_scheduled_duration | woke_at > 0 |
total_short_delay_count、total_short_delay_duration、total_long_delay_count、total_long_delay_duration | 按 long_delay_threshold 分 | |
| poll | total_fast_poll_count、total_fast_poll_duration、total_slow_poll_count、total_slow_poll_duration | 按 slow_poll_threshold 分 |
total_poll_count、total_poll_duration | 读的时候快慢相加,第 2334 行 |
几条从源码直接推出来的关系:
total_poll_count = total_fast_poll_count + total_slow_poll_count,时长同理。total_scheduled_count = total_short_delay_count + total_long_delay_count,时长同理。这一对是写的时候分别加的,不是读的时候算的,在读到一半的边界上可能差 1。instrumented_count - dropped_count是在途数:已经包装、还没被 drop 的 future,包括包了还没 spawn 的。first_poll_count <= instrumented_count,差值是包了但一次都没被 poll 过的。
max_idle_duration 背后有两个原子变量:global_max_idle_duration_ns 从 monitor 创建起只增不减,cumulative() 读它;local_max_idle_duration_ns 在 intervals() 每次取值时被 swap(0) 清零,表示「本区间内的最大空闲」。后者被清零意味着同一个 monitor 开两个 intervals() 会互相抢走这个值,03 会实测。
下面的程序检查在途数、快慢两桶和默认阈值:
use std::time::{Duration, Instant};
use tokio_metrics::{TaskMetrics, TaskMonitor};
fn spin(d: Duration) {
let t = Instant::now();
while t.elapsed() < d {}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
// 1. 一次 poll 都没有时的派生指标
let empty = TaskMetrics::default();
println!(
"empty: mean_poll={:?} mean_sched={:?} slow_poll_ratio={} long_delay_ratio={}",
empty.mean_poll_duration(), empty.mean_scheduled_duration(),
empty.slow_poll_ratio(), empty.long_delay_ratio()
);
// 2. 在途数 = instrumented - dropped
let m = TaskMonitor::new();
let never_polled = m.instrument(async {}); // 包了,还没 spawn
let pending = tokio::spawn(m.instrument(std::future::pending::<()>())); // 永远不完成
tokio::spawn(m.instrument(async { spin(Duration::from_micros(80)) })).await.unwrap();
tokio::task::yield_now().await; // 让 pending 那个跑一次
let c = m.cumulative();
println!(
"in flight: instrumented={} dropped={} -> {}; first_polls={}",
c.instrumented_count, c.dropped_count, c.instrumented_count - c.dropped_count, c.first_poll_count
);
println!(
"polls: total={} fast={} slow={} slow_poll_ratio={:.2} thresholds={:?}/{:?}",
c.total_poll_count, c.total_fast_poll_count, c.total_slow_poll_count, c.slow_poll_ratio(),
m.slow_poll_threshold(), m.long_delay_threshold()
);
drop(never_polled);
pending.abort();
let _ = pending.await;
let c = m.cumulative();
println!("after drop/abort: instrumented={} dropped={}", c.instrumented_count, c.dropped_count);
}
依赖同前(tokio-metrics = "=0.5.2"、tokio = { version = "=1.54.1", features = ["full"] })。M2 Max 笔记本上连跑 3 次,输出完全相同:
empty: mean_poll=0ns mean_sched=0ns slow_poll_ratio=NaN long_delay_ratio=NaN
in flight: instrumented=3 dropped=1 -> 2; first_polls=2
polls: total=2 fast=1 slow=1 slow_poll_ratio=0.50 thresholds=50µs/50µs
after drop/abort: instrumented=3 dropped=3
- 第二行:包了 3 个,跑完的那个已被 drop;剩下一个没 spawn、一个永远
Pending,在途 2 个。被 poll 过的是 2 个(没 spawn 的那个没有)。 - 第三行:忙 80µs 的那次 poll 超过 50µs,是慢 poll;
pending()那次是快 poll。总数 2 是两桶之和。 - 第四行:drop 掉没 spawn 的 future、
abort掉挂起的任务,dropped_count都会加 1。被 abort 的任务,tokio 在取消时 drop 它的 future(见 tokio 卷 JoinHandle),PinnedDrop照样执行。
derived_metrics!:派生指标
派生指标是 TaskMetrics 上的方法,写在一个 derived_metrics! 宏调用里。宏本身在 derived_metrics.rs,一共 37 行:
macro_rules! derived_metrics {
(
[$metrics_name:ty] {
stable {
$(
$(#[$($attributes:tt)*])*
$vis:vis fn $name:ident($($args:tt)*) -> $ty:ty $body:block
)*
}
unstable {
// ... 同上,换一组变量名
}
}
) => {
impl $metrics_name {
$(
$(#[$($attributes)*])*
$vis fn $name($($args)*) -> $ty $body
)*
$(
$(#[$($unstable_attributes)*])*
#[cfg(tokio_unstable)]
$unstable_vis fn $unstable_name($($unstable_args)*) -> $unstable_ty $unstable_body
)*
#[cfg(all(test, feature = "metrics-rs-integration"))]
const DERIVED_METRICS: &[&str] = &[$(stringify!($name),)*];
// ...
}
};
}
它不生成任何计算逻辑:每个方法的函数体都是手写的,宏只做三件事,原样放进一个 impl 块;给 unstable 组的每个方法加 #[cfg(tokio_unstable)];在测试里生成一个方法名列表,让 metrics-rs 集成的测试检查「每个派生指标都导出了」。任务指标的 unstable 组是空的;RuntimeMetrics 用同一个宏,它的两个派生指标里 busy_ratio 在 stable 组,mean_polls_per_park 在 unstable 组,后者不开 tokio_unstable 就不存在。
TaskMetrics 的 10 个派生指标:
| 方法 | 分子 ÷ 分母 | 分母为 0 时 |
|---|---|---|
mean_first_poll_delay | total_first_poll_delay ÷ first_poll_count | 0 |
mean_idle_duration | total_idle_duration ÷ total_idled_count | 0 |
mean_scheduled_duration | total_scheduled_duration ÷ total_scheduled_count | 0 |
mean_poll_duration | total_poll_duration ÷ total_poll_count | 0 |
mean_fast_poll_duration | total_fast_poll_duration ÷ total_fast_poll_count | 0 |
mean_slow_poll_duration | total_slow_poll_duration ÷ total_slow_poll_count | 0 |
mean_short_delay_duration | total_short_delay_duration ÷ total_short_delay_count | 0 |
mean_long_delay_duration | total_long_delay_duration ÷ total_long_delay_count | 0 |
slow_poll_ratio | total_slow_poll_count ÷ total_poll_count | NaN |
long_delay_ratio | total_long_delay_count ÷ total_scheduled_count | NaN |
八个均值都走同一个 mean:
fn mean(d: Duration, count: u64) -> Duration {
if let Some(quotient) = to_nanos(d).checked_div(count) {
Duration::from_nanos(quotient)
} else {
Duration::ZERO
}
}
checked_div 在除数为 0 时返回 None,均值就是 0。两个比率则是直接的浮点除法,第 2677 行:
pub fn slow_poll_ratio(&self) -> f64 {
self.total_slow_poll_count as f64 / self.total_poll_count as f64
}
/// The ratio of tasks exceeding [`long_delay_threshold`][TaskMonitor::long_delay_threshold].
// ...
pub fn long_delay_ratio(&self) -> f64 {
self.total_long_delay_count as f64 / self.total_scheduled_count as f64
}
0.0 / 0.0 是 NaN,上面实验的第一行就是 TaskMetrics::default() 算出来的。用 intervals() 做差时,一个区间里没有任何 poll(任务都在等外部)是常事,这时 slow_poll_ratio 就是 NaN;有的看板会把 NaN 画成断点,有的导出器会拒收。均值和比率在「没有样本」时的处理不一致,导出前自己判断分母更稳妥。
slow_poll_ratio 的文档第一句写的是「The ratio between the number polls categorized as slow and fast」(慢与快之比),README 也这么写;但 Definition 一节和代码都是慢 poll 数 ÷ 总 poll 数,范围是 0 到 1。以代码为准。
两个阈值
快慢、长短的分界线是 monitor 构造时定的,第 1802 行:
#[cfg(not(test))]
pub const DEFAULT_SLOW_POLL_THRESHOLD: Duration = Duration::from_micros(50);
#[cfg(test)]
#[allow(missing_docs)]
pub const DEFAULT_SLOW_POLL_THRESHOLD: Duration = Duration::from_millis(500);
// ...
#[cfg(not(test))]
pub const DEFAULT_LONG_DELAY_THRESHOLD: Duration = Duration::from_micros(50);
#[cfg(test)]
#[allow(missing_docs)]
pub const DEFAULT_LONG_DELAY_THRESHOLD: Duration = Duration::from_millis(500);
默认都是 50µs,实验里 thresholds=50µs/50µs 印证了这一点。库自己跑单元测试时(cfg(test))两个默认值换成 500ms,源码没写原因;cfg(test) 只在编译本库自己的测试时成立,用户的依赖构建拿到的永远是 50µs。
改阈值有两种写法:TaskMonitor::with_slow_poll_threshold(d) 只改慢 poll 的阈值;两个都要改用 builder:
let mut b = TaskMonitor::builder();
b.with_slow_poll_threshold(Duration::from_millis(1))
.with_long_delay_threshold(Duration::from_millis(1));
let monitor = b.build();
with_slow_poll_threshold 的文档在「Selecting an appropriate threshold」小节下只写了一个 TODO. What advice can we give here?(第 1834 行),库没有给建议。50µs 对 CPU 密集的服务偏低,几乎所有 poll 都会被记成慢的;比较实际的做法是先用默认值跑一段,看 mean_fast_poll_duration 和 mean_slow_poll_duration 的分布再定。两个比较都用 >=:正好等于阈值算慢、算长。
小结
RawMetrics是 18 个只增不减的AtomicU64加两个阈值;没有 poll 总数,总数是读的时候快慢两桶相加。metrics()逐个load(SeqCst),19 个字段不是同一时刻的快照。- 在途数 =
instrumented_count - dropped_count;drop、abort、正常完成都会让dropped_count加 1。 max_idle_duration有全局和区间两个原子变量,区间那个每次intervals()取值时清零。derived_metrics!只把手写的方法放进impl、给 unstable 组加 cfg、生成测试用的名字表;8 个均值分母为 0 时得 0,2 个比率得 NaN;slow_poll_ratio是慢 ÷ 总,不是慢 ÷ 快。- 两个阈值默认 50µs,比较用
>=;只改慢 poll 阈值有快捷构造,改排队阈值要用 builder。