Rust in Everything
基于 tokio-metrics 0.5.2

metrics-rs 与 metrique

主线位置:两条主线的出口。前一页的 intervals() 把累计值切成区间;本页讲 metrics-rs-integration feature 怎么每隔一段时间取一个区间、交给 metrics-rs 的 recorder,以及每个字段被当成了 counter 还是 gauge。

flowchart TB
  tm["TaskMonitor"] --> b["TaskMetricsReporterBuilder::new(transformer)"]
  b -->|"describe()"| d["describe_counter / describe_gauge:名字、单位、说明"]
  b -->|"build_with_monitor"| r["TaskMetricsReporter"]
  r --> cap["capture:按名字取好 Counter、Gauge 句柄"]
  r --> loop["run():run_once() 后 sleep(30s),无限循环"]
  loop --> nx["intervals().next():本区间的 TaskMetrics"]
  nx --> emit["emit:Counter 加增量,Gauge 设新值"]
  emit --> rec["全局 recorder,比如 Prometheus 导出器"]

两个 builder

feature metrics-rs-integration 打开后多出两组类型,依赖 metrics 0.24。

任务这边是 TaskMetricsReporterBuilder::new,参数是一个必填的名字转换函数:

    pub fn new(transformer: impl FnMut(&'static str) -> metrics::Key + Send + 'static) -> Self {
        TaskMetricsReporterBuilder {
            interval: DEFAULT_METRIC_SAMPLING_INTERVAL,
            metrics_transformer: Box::new(transformer),
        }
    }

运行时这边 RuntimeMetricsReporterBuilder 实现了 Default,转换函数默认是 Key::from_static_name,要改用 with_metrics_transformer。它的 build() 用 Handle::current() 取运行时,所以要在运行时里面调。

采样周期默认 30 秒(metrics_rs.rs 第 3 行 的 DEFAULT_METRIC_SAMPLING_INTERVAL),with_interval 可改。describe_and_run 是 describe()、build_with_monitor()、run() 三步连起来,而 run() 是个永不返回的循环:

    pub async fn run(mut self) {
        loop {
            self.run_once();
            tokio::time::sleep(self.interval).await;
        }
    }

所以它要 tokio::spawn 出去跑,不能直接 .await。run_once() 是公开的,想自己控制取样时刻(比如和别的采集对齐)就不用 run(),自己循环调 run_once()。

任务的 build_with_monitor 只收 TaskMonitor,不收 &'static TaskMonitorCore;用 static 的 monitor 要自己写导出。

名字:统一加 tokio_ 前缀

每个字段的名字由 metric_key! 拼出来,然后交给转换函数:

macro_rules! metric_key {
    ($transform_fn:ident, $name:ident) => {
        $transform_fn(concat!("tokio_", stringify!($name)))
    };
}

转换函数收到的是 "tokio_total_poll_count" 这样的 &'static str,返回一个 metrics::Key,可以改名,也可以加标签。任务指标和运行时指标都用 tokio_ 前缀,字段名也有重叠的(两边都有 mean_poll_duration),文档建议「每个 TaskMonitor 一个独立的名字或维度值」。按路由分 monitor 时,最省事的是加一个 route 标签,下面的实验就这么做。

metric_refs!:字段的类型写死在宏调用里

两个 reporter 的主体都是一次 metric_refs! 宏调用。宏生成一个结构体,每个字段存一个 metrics::Counter、Gauge 或 Histogram 句柄,外加三个方法:capture 在 build 时按名字从当前的 recorder 取好所有句柄;describe 登记说明和单位;emit 每个区间把值写进句柄。任务这边的调用在 task/metrics_rs_integration.rs 第 214 行:

metric_refs! {
    [TaskMetricRefs] [elapsed] [TaskMetrics] [()] {
        stable {
            /// The number of tasks instrumented.
            instrumented_count: Gauge<Count> [],
            // ...
            /// The total duration elapsed during polls.
            total_poll_duration: Counter<Microseconds> [],
            // ...
        }
        stable_derived {
            // ...
            /// The mean duration of polls.
            mean_poll_duration: Counter<Microseconds> [],
            /// The ratio between the number polls categorized as slow and fast.
            slow_poll_ratio: Gauge<Percent> [],
            // ...
        }
        unstable {}
        unstable_derived {}
    }
}

句柄在 build 时取,意味着先装 recorder 再 build;先 build 的话,句柄指向的是当时的空 recorder,之后数据全丢,不报错。文档在 build_with_monitor 上提醒了用 with_local_recorder 的人。

Counter 和 Gauge 收到值以后做什么,由 MyMetricOp 的几个实现决定:

impl<T> MyMetricOp<T> for (&metrics::Counter, Duration) {
    fn op(self, _: T) {
        self.0
            .increment(self.1.as_micros().try_into().unwrap_or(u64::MAX));
    }
}

impl<T> MyMetricOp<T> for (&metrics::Counter, u64) {
    fn op(self, _t: T) {
        self.0.increment(self.1);
    }
}

impl<T> MyMetricOp<T> for (&metrics::Gauge, Duration) {
    fn op(self, _t: T) {
        self.0.set(self.1.as_micros() as f64);
    }
}
// ... Gauge 收 u64、usize、f64 都是 set

传进来的值是 intervals().next() 的结果,也就是本区间的差值。Counter 把差值加上去,导出的是累计值;Gauge 直接设成差值,导出的是「最近一个区间」的值。时长一律转成微秒,as_micros() 截断,每个区间丢掉不足 1 微秒的零头。

于是任务指标导出后是这样:

字段类型导出的值
instrumented_count、dropped_count、first_poll_count、各种 *_countGauge最近一个区间里的次数,不是累计次数
total_*_duration、total_first_poll_delayCounter累计微秒数,可以 rate()
max_idle_durationCounter每个区间的最大空闲加起来
8 个 mean_*Counter每个区间的均值加起来
slow_poll_ratio、long_delay_ratioGauge(单位 Percent)0 到 1 的小数,没有 poll 的区间是 NaN

后两行的值在 Prometheus 里没有直接的意义:均值的和不是均值,最大值的和也不是最大值。实验看得更清楚。

实验:导出到 Prometheus 文本

依赖:

tokio-metrics = { version = "=0.5.2", features = ["rt", "metrics-rs-integration"] }
tokio = { version = "=1.54.1", features = ["full"] }
metrics = "=0.24.6"
metrics-exporter-prometheus = { version = "=0.17.2", default-features = false }

不起 HTTP 服务,build_recorder() 拿到 recorder 和一个 PrometheusHandle,render() 直接生成 Prometheus 文本。三个区间:前两个各跑 10 个任务(每个两次 poll,一慢一快),第三个什么都不做:

use std::time::{Duration, Instant};
use metrics::Key;
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use tokio_metrics::{TaskMetricsReporterBuilder, TaskMonitor};

fn spin(d: Duration) {
  let t = Instant::now();
  while t.elapsed() < d {}
}

// 10 个任务,每个 poll 两次:第一次忙 100µs(慢),yield 之后第二次立刻完成(快)
async fn round(m: &TaskMonitor) {
  for _ in 0..10 {
    tokio::spawn(m.instrument(async {
      spin(Duration::from_micros(100));
      tokio::task::yield_now().await;
    }))
    .await
    .unwrap();
  }
}

const KEEP: [&str; 6] = ["instrumented_count", "total_poll_count", "total_poll_duration", "mean_poll_duration", "slow_poll_ratio", "max_idle_duration"];

// 只打印这 6 个指标;prefix 为 "" 时打印数值行,为 "# TYPE" 时打印类型行
fn show(handle: &PrometheusHandle, prefix: &str) {
  let mut lines: Vec<String> = handle
    .render()
    .lines()
    .filter(|l| l.starts_with(prefix) && (prefix.starts_with('#') || !l.starts_with('#')))
    .filter(|l| KEEP.iter().any(|k| l.contains(&format!("tokio_{k}{{")) || l.ends_with(&format!("tokio_{k} counter")) || l.ends_with(&format!("tokio_{k} gauge"))))
    .map(String::from)
    .collect();
  lines.sort_by_key(|l| KEEP.iter().position(|k| l.contains(&format!("tokio_{k}"))));
  println!("{}", lines.join("\n"));
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let recorder = PrometheusBuilder::new().build_recorder();
  let handle = recorder.handle();
  metrics::set_global_recorder(recorder).unwrap();

  let monitor = TaskMonitor::new();
  // 先装好 recorder,再 build:指标句柄在 build 时就取好了
  let mut reporter = TaskMetricsReporterBuilder::new(|name| Key::from_parts(name, &[("route", "search")]))
    .describe()
    .build_with_monitor(monitor.clone());

  for (i, work) in [true, true, false].into_iter().enumerate() {
    if work {
      round(&monitor).await;
    }
    reporter.run_once(); // 不用 run() 的定时循环,手动取一个区间
    println!("--- after interval {} (work={work})", i + 1);
    show(&handle, "tokio_");
  }
  println!("---");
  show(&handle, "# TYPE");
}

M2 Max 笔记本、--release,连跑 3 次,除 total_poll_duration 差一两微秒、max_idle_duration 在几十微秒内变化外完全相同。其中一次:

--- after interval 1 (work=true)
tokio_instrumented_count{route="search"} 10
tokio_total_poll_count{route="search"} 20
tokio_total_poll_duration{route="search"} 1001
tokio_mean_poll_duration{route="search"} 50
tokio_slow_poll_ratio{route="search"} 0.5
tokio_max_idle_duration{route="search"} 26
--- after interval 2 (work=true)
tokio_instrumented_count{route="search"} 10
tokio_total_poll_count{route="search"} 20
tokio_total_poll_duration{route="search"} 2006
tokio_mean_poll_duration{route="search"} 100
tokio_slow_poll_ratio{route="search"} 0.5
tokio_max_idle_duration{route="search"} 38
--- after interval 3 (work=false)
tokio_instrumented_count{route="search"} 0
tokio_total_poll_count{route="search"} 0
tokio_total_poll_duration{route="search"} 2006
tokio_mean_poll_duration{route="search"} 100
tokio_slow_poll_ratio{route="search"} NaN
tokio_max_idle_duration{route="search"} 38
---
# TYPE tokio_instrumented_count gauge
# TYPE tokio_total_poll_count gauge
# TYPE tokio_total_poll_duration counter
# TYPE tokio_mean_poll_duration counter
# TYPE tokio_slow_poll_ratio gauge
# TYPE tokio_max_idle_duration counter
  • total_poll_count 每个区间都是 20,没事做时是 0:它是 gauge,值是本区间的次数。名字里的 total 指的是「快慢两桶之和」,不是「从启动起的累计」。Prometheus 只在抓取时看到最后一次设的值,抓取周期和采样周期(默认 30 秒)不一致时,中间的区间会被跳过或重复。
  • total_poll_duration 一直往上加:1001 → 2006 → 2006,counter,单位微秒,rate() 可用。
  • mean_poll_duration 是 50、100、100:每个区间的均值都是约 50µs(1001µs ÷ 20 次),counter 把它们加了起来。按默认 30 秒一个区间,一小时后这个数是 6000 左右,却仍然叫「mean」。
  • max_idle_duration 同理:每次 yield_now 造成一段几十微秒以内的空闲(包装 waker 里讲过,yield_now 被记成空闲),两个区间的最大值相加成了 38。
  • slow_poll_ratio 在空区间是 NaN:导出器原样输出 NaN。它的 # HELP 说明来自宏调用里的文档注释「The ratio between the number polls categorized as slow and fast」,和代码(慢 ÷ 总)不符,在 TaskMetrics 与派生指标里说过。单位登记为 Percent,值却是 0 到 1 的小数。

想在 Prometheus 里得到「本区间平均每次 poll 多长」,可以用的只有 counter 的 total_poll_duration;可次数是 gauge,两者口径不同,拼不出可靠的均值。需要准确的均值或比例,比较稳妥的做法是不用这套集成,自己从 cumulative() 取值、全部按 counter 导出,在查询端做除法(前一页的「cumulative() 还是 intervals()」)。

运行时指标的映射

运行时的宏调用在 runtime/metrics_rs_integration.rs 第 247 行,口径又和任务这边不同。以下是读源码得到的映射,没有逐个导出验证:

字段类型
workers_count、live_tasks_count、global_queue_depthGauge(瞬时值,合适)
total/max/min_park_countGauge(本区间的次数)
total/max/min_busy_durationCounter(max、min 也是逐区间相加)
busy_ratioGauge(单位 Percent,0 到 worker 数)
unstable 的各种 *_count:poll、窃取、溢出、noop、远程调度、预算、IO 就绪Counter(累计,可以 rate()),其中 max_*、min_* 也是逐区间相加
mean_poll_duration 及其 worker 极值Gauge(微秒)
本地队列深度、阻塞池三项Gauge(瞬时值)
poll_time_histogramHistogram
mean_polls_per_parkGauge(单位 Percent)

同样是次数,任务这边是 gauge,运行时 unstable 的是 counter,运行时稳定的 park 次数又是 gauge。elapsed 两边都在宏的忽略列表里(宏调用第二个方括号里的 [elapsed]),不导出。

直方图交给 metrics-rs 时,每个桶用起点代表整个桶(metrics_rs.rs 第 287 行):

#[cfg(all(feature = "rt", tokio_unstable))]
impl<T> MyMetricOp<T> for (&metrics::Histogram, crate::runtime::PollTimeHistogram) {
    fn op(self, _: T) {
        for bucket in self.1.buckets() {
            if bucket.count() > 0 {
                // Use range.start as the representative value; the metrics-rs
                // histogram handles its own bucketing from these raw values.
                self.0.record_many(
                    bucket.range_start().as_micros() as f64,
                    bucket.count() as usize,
                );
            }
        }
    }
}

默认的线性桶下,0–100µs 的 poll 全部记成 0,900µs 以上的全部记成 900µs,下游再怎么分桶也恢复不了原来的分布。这一段没有实测导出结果。

metrique

feature metrique-integration(0.5.0 加入,#121)走的是另一种思路:不定期推送,而是把指标结构体当成 metrique 的「一次工作的记录」(unit of work)里的一个子字段。RuntimeMetrics 上有一行 #[cfg_attr(feature = "metrique-integration", metrique::unit_of_work::metrics(subfield_owned))](见上一部分的稳定与不稳定两层字段),FutureMetrics 上也有一行(第 3084 行,用的是 subfield 而不是 subfield_owned),这样一个请求的日志条目里可以直接嵌一份运行时区间或这个请求的 future 指标。PollTimeHistogram 在 metrique 那边用桶的中点代表整个桶,最后一个桶用起点(poll_time_histogram.rs 第 82 行),和 metrics-rs 这边用起点不同。metrique 没有在本卷实测。

小结

  • TaskMetricsReporterBuilder::new(transformer) 必须给转换函数,运行时的 builder 有默认值;默认 30 秒采样一次,run() 是无限循环,要 spawn 出去;run_once() 可以自己调。
  • 名字统一是 tokio_ 加字段名,任务和运行时有同名字段,多个 monitor 要靠转换函数改名或加标签区分。
  • 指标句柄在 build 时从当前 recorder 取,先装 recorder 再 build。
  • 值是 intervals() 的区间差:Counter 加差值,Gauge 设成差值。任务的次数是本区间的 gauge,时长是累计的 counter,而 8 个均值和最大空闲也是 counter,导出后是「每个区间的均值之和」,实测 50 → 100;比率是 0 到 1,空区间导出 NaN。
  • 运行时 unstable 的次数是 counter,和任务这边相反;直方图每个桶用起点代表,metrique 用中点。

💬 关联讨论

发起讨论
加载中...