Rust in Everything
基于 tokio-metrics 0.5.2

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_countinstrument_with
dropped_countPinnedDrop
first_poll_count、total_first_poll_delay首次 poll
空闲total_idled_count、total_idle_duration、max_idle_durationidled_at < woke_at
排队total_scheduled_count、total_scheduled_durationwoke_at > 0
total_short_delay_count、total_short_delay_duration、total_long_delay_count、total_long_delay_duration按 long_delay_threshold 分
polltotal_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_delaytotal_first_poll_delay ÷ first_poll_count0
mean_idle_durationtotal_idle_duration ÷ total_idled_count0
mean_scheduled_durationtotal_scheduled_duration ÷ total_scheduled_count0
mean_poll_durationtotal_poll_duration ÷ total_poll_count0
mean_fast_poll_durationtotal_fast_poll_duration ÷ total_fast_poll_count0
mean_slow_poll_durationtotal_slow_poll_duration ÷ total_slow_poll_count0
mean_short_delay_durationtotal_short_delay_duration ÷ total_short_delay_count0
mean_long_delay_durationtotal_long_delay_duration ÷ total_long_delay_count0
slow_poll_ratiototal_slow_poll_count ÷ total_poll_countNaN
long_delay_ratiototal_long_delay_count ÷ total_scheduled_countNaN

八个均值都走同一个 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。

💬 关联讨论

发起讨论
加载中...