Rust in Everything
基于 tokio-metrics 0.5.2

怎么读这一卷

主线位置:两条主线的总览。本页把任务主线和运行时主线各画一遍,用一个程序把两条线都跑一遍,再给出源码地图。
flowchart TB
  start[从这里开始] --> q{读这一卷为了什么}
  q -->|会用| use1["00 全景、01 TaskMetrics"]
  use1 --> use2[03 采样与导出]
  use2 --> use3[05 常见坑]
  q -->|读懂实现| all[00 到 07 按顺序]

锁定版本

项值
cratetokio-metrics 0.5.2
tagv0.5.2
commit2d19218(2026-08-28,chore(release): v0.5.2 (#135))
实验用的依赖tokio 1.54.1(和 tokio 卷相同),futures 0.3
实验环境M2 Max 笔记本(12 核)、macOS、rustc 1.99.0、--release

源码链接都用 tag,形如 https://github.com/tokio-rs/tokio-metrics/blob/v0.5.2/src/task.rs#L2900。写作时仓库的 main 比 tag 多一个提交(#137),给 RuntimeMetrics 加了排队延迟直方图,把 runtime/poll_time_histogram.rs 改名成了 histogram.rs。这些不在 0.5.2 里,读 main 会看到本卷没讲的字段,行号也对不上。

想在本地对照着读:

git clone https://github.com/tokio-rs/tokio-metrics
cd tokio-metrics
git checkout v0.5.2

两条主线

任务主线(包一层 future):用户先 monitor.instrument(fut) 把 future 包起来,再交给 tokio::spawn。之后的每一次 poll 都先经过 Instrumented,它在内层 future 外面记时间、换 waker。

sequenceDiagram
  participant U as 用户代码
  participant I as Instrumented
  participant S as State(包装 waker)
  participant F as 内层 future
  participant T as tokio 任务
  U->>I: monitor.instrument(fut),instrumented_count 加 1
  T->>I: poll(cx)
  I->>I: 第一次?记首次 poll 延迟
  I->>S: 取出并清零 woke_at,算空闲和排队
  I->>S: 存下 cx.waker()
  I->>F: 用 State 做 waker 去 poll
  F-->>I: Pending
  I->>I: poll 耗时按阈值记快或慢,记下 idled_at
  F->>S: 将来某个时刻 wake()
  S->>S: woke_at 为 0 时记下现在
  S->>T: 转给真正的 waker
  T->>I: 再次 poll(cx)

图里每一步对应的源码在 Instrumented 与 instrument_poll 和 包装 waker 两页。

运行时主线(读计数器做差):tokio 自己在每个 worker 上维护累计计数器,Handle::metrics() 能读到;tokio-metrics 不改 tokio,只是定期读、做差、聚合。

flowchart TB
  A["RuntimeMonitor::new(&handle)"] --> B["存下 handle.metrics()"]
  B --> C["intervals():每个 worker 一个快照"]
  C --> D["next():逐个 worker 读累计值"]
  D --> E["减去上次的值,得到本区间增量"]
  E --> F["求和、求最大最小,算出 busy_ratio 等"]
  F --> G["返回 RuntimeMetrics"]
  G -->|"调用方 sleep 一会儿"| D

两条线最后是同一个模式:写的一方只累加,读的一方做差。TaskMonitor::intervals() 和 RuntimeMonitor::intervals() 都返回普通的 Iterator,不是 Stream,它的 next() 永远返回 Some,多久采一次样由调用方自己 sleep 决定。

下面这段程序把两条线都跑了一遍:8 个任务,每个循环 5 次「忙 2ms,再睡 20ms」,跑在 4 个 worker 的运行时上。

tokio-metrics = "=0.5.2"
tokio = { version = "=1.54.1", features = ["full"] }
use std::time::{Duration, Instant};
use tokio_metrics::{RuntimeMonitor, TaskMonitor};

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

#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
  // 任务主线:一个 monitor 聚合它包住的所有任务
  let monitor = TaskMonitor::new();
  // 运行时主线:只存一个 tokio 的 RuntimeMetrics 句柄
  let runtime = RuntimeMonitor::new(&tokio::runtime::Handle::current());
  let mut task_iv = monitor.intervals();
  let mut rt_iv = runtime.intervals();
  task_iv.next(); // 第一次返回累计值(此时全是 0),之后返回差分
  rt_iv.next();

  let handles: Vec<_> = (0..8)
    .map(|_| {
      tokio::spawn(monitor.instrument(async {
        for _ in 0..5 {
          spin(Duration::from_millis(2)); // 一次 poll 里忙 2ms:慢 poll
          tokio::time::sleep(Duration::from_millis(20)).await; // 等定时器:空闲
        }
      }))
    })
    .collect();
  for h in handles {
    h.await.unwrap();
  }

  let t = task_iv.next().unwrap();
  println!(
    "task: instrumented={} dropped={} polls={} slow={} mean_poll={:?}",
    t.instrumented_count, t.dropped_count, t.total_poll_count, t.total_slow_poll_count, t.mean_poll_duration()
  );
  println!(
    "task: idled={} mean_idle={:?} scheduled={} mean_scheduled={:?}",
    t.total_idled_count, t.mean_idle_duration(), t.total_scheduled_count, t.mean_scheduled_duration()
  );
  let r = rt_iv.next().unwrap();
  println!(
    "runtime: workers={} busy_ratio={:.2} total_park={} elapsed={:?}",
    r.workers_count, r.busy_ratio(), r.total_park_count, r.elapsed
  );
}

其中一次的输出:

task: instrumented=8 dropped=8 polls=48 slow=40 mean_poll=1.667542ms
task: idled=40 mean_idle=21.880919ms scheduled=40 mean_scheduled=13.28µs
runtime: workers=4 busy_ratio=0.66 total_park=38 elapsed=121.3705ms

连跑 13 次,计数一次不差:polls=48 slow=40 idled=40 scheduled=40;mean_poll 在 1.667–1.669ms,mean_idle 在 21.7–22.0ms,busy_ratio 在 0.66–0.67,total_park 在 34–37;变化最大的是 mean_scheduled,多数在 11–19µs,13 次里有一次到 114µs。

逐项对应:

  • polls=48 slow=40:每个任务 poll 6 次。前 5 次各忙 2ms 后在 sleep 上返回 Pending,都超过默认的 50µs 阈值,记为慢 poll;第 6 次 sleep 已到期、循环结束,几微秒就返回,记为快 poll。8 × 6 = 48,8 × 5 = 40。mean_poll = (40 × 2ms + 8 次快 poll) / 48 ≈ 1.667ms。
  • idled=40 scheduled=40:每次 sleep 返回 Pending 之后,任务从「poll 结束」到「定时器唤醒它」是一段空闲,从「被唤醒」到「下次被 poll」是一段排队。5 次 × 8 个任务 = 40。空闲比 20ms 多出将近 2ms:tokio 的时间轮精度是 1 毫秒,而且定时器要等驱动醒来处理时才触发(见 tokio 卷时间轮),woke_at 记的是 wake() 被调用的时刻,不是 deadline。
  • mean_scheduled 抖得最厉害:8 个任务挤 4 个 worker,两个任务的 2ms 忙等撞在同一个 worker 上,后一个就得在队列里等。哪次撞上、撞几次,每次运行都不一样。
  • busy_ratio=0.66:8 个任务 × 5 次 × 2ms = 80ms 的 CPU 时间,除以 121ms 的墙钟时间。注意它是各 worker 忙碌时间之和除以墙钟时间,范围是 0 到 worker 数,不是 0 到 1,细节放在 Worker 差分与 poll 时间直方图。

程序开头两边都先空调了一次 next(),但原因不同:TaskIntervals 第一次返回的是从 monitor 创建到现在的累计值,之后才是差分;RuntimeIntervals 在 intervals() 被调用时就记下了每个 worker 的基准,第一次 next() 已经是差分。空调一次让两个区间的起点对齐。

阅读路线

  • 只想会用:读 00 全景、01 的 TaskMetrics 与派生指标,以及 03 采样与导出、05 常见坑。读完能看懂每个字段量的是哪一段时间,知道 slow_poll_ratio 为什么会是 NaN、busy_ratio 为什么会大于 1。
  • 想读懂实现:按顺序读。01 是全卷唯一有算法的地方,四页讲完任务主线;02 每个运行时字段都链到 tokio 卷里产生它的那段调度器代码;07 从零写一个最小的 Instrumented。

摘自 tokio-metrics 的代码片段保留原来的 4 空格缩进,方便和原文件对照;本站自己写的示例用 2 空格缩进。

前置知识

这一卷默认你会写 async Rust。被量的那些机制在 tokio 卷里讲过,这里只链过去:

概念tokio 卷本卷用到它的地方
Future::poll、Waker、任务被唤醒后进哪个队列poll 与唤醒Instrumented 换掉的就是这个 waker
任务状态机一个 usize 的状态机tokio 自己的「已通知」位和本库的 woke_at 对照
worker 取任务的顺序worker 主循环「排队」量的是这段等待
协作式预算、yield_now 的 defer协作式调度预算这类让出被记成空闲,而不是排队
tokio_unstable源码地图运行时指标里一大半字段要它
Builder 上的 metrics 开关Builder、Runtime 与 Handlepoll 时间直方图要先在这里打开

tracing 卷的 Instrument 与异步 讲的 Instrumented 和本卷的 Instrumented 是同一个套路:包住 future,在每次 poll 的前后做一件事。tracing 做的是进出 span,本库做的是记时间;本库多了一步,换掉 waker。

源码地图

v0.5.2 的 src/ 一共 8 个文件、6 905 行,去掉注释和空行 2 361 行(含单元测试):

文件行数去掉注释管什么本卷
task.rs3 6731 024TaskMonitor、TaskMonitorCore、Instrumented、TaskMetrics、FutureMonitor、TaskIntervals01、03
runtime.rs1 779516RuntimeMonitor、RuntimeMetrics、RuntimeIntervals、Worker 快照02、03
runtime/poll_time_histogram.rs212156PollTimeHistogram、HistogramBucket02
metrics_rs.rs301269把字段按 Counter / Gauge / Histogram 分类的宏03
task/metrics_rs_integration.rs312113TaskMetricsReporterBuilder03
runtime/metrics_rs_integration.rs372139RuntimeMetricsReporterBuilder03
lib.rs219109crate 文档、cfg_rt!、pub use本页
derived_metrics.rs3735derived_metrics! 宏TaskMetrics 与派生指标

task.rs 的 3 673 行里有 2 374 行是 /// 文档注释:TaskMetrics 的每个字段都带一段可运行的示例。任务主线真正的算法只有 instrument_poll 和 State::on_wake 两个函数,一共 170 行。仓库里另有 4 个 example、1 个 bench(poll_overhead,量包一层之后每次 poll 多花多少,04 设计取舍会用到)。

三个 feature

Cargo.toml 第 23 行:

[features]
default = ["rt"]
metrics-rs-integration = ["dep:metrics"]
metrique-integration = ["dep:metrique"]
rt = ["tokio"]
feature默认打开什么
rt开依赖 tokio(rt、time、net);只有它打开时才有 RuntimeMonitor、RuntimeMetrics;任务指标的时钟换成 tokio::time::Instant
metrics-rs-integration关TaskMetricsReporterBuilder、RuntimeMetricsReporterBuilder,把指标交给 metrics.rs 的导出器
metrique-integration关给 RuntimeMetrics、FutureMetrics 加上 metrique 的 unit_of_work 派生

任务指标不要求 rt。不开 rt 时,task.rs 开头换一个时钟:

#[cfg(feature = "rt")]
use tokio::time::{Duration, Instant};

use crate::derived_metrics::derived_metrics;
#[cfg(not(feature = "rt"))]
use std::time::{Duration, Instant};

两种 Instant 在生产里没有区别:tokio::time::Instant::now() 在 tokio 没开 test-util 时就是 std::time::Instant::now()。区别出现在测试里:打开 test-util 并暂停时钟后,tokio 的 Instant 读的是虚拟时钟。下面的程序在暂停的时钟上「睡」一小时,分别不开、开 rt 跑一次:

tokio-metrics = { version = "=0.5.2", default-features = false }
tokio = { version = "=1.54.1", features = ["full", "test-util"] }

[features]
rt = ["tokio-metrics/rt"]
use std::time::Duration;
use tokio_metrics::TaskMonitor;

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
  let m = TaskMonitor::new();
  let real = std::time::Instant::now();
  let fut = m.instrument(async {});
  tokio::time::sleep(Duration::from_secs(3600)).await; // 暂停的时钟:瞬间「过去」一小时
  fut.await;
  println!(
    "rt feature: {} first_poll_delay={:?} (real elapsed {:?})",
    cfg!(feature = "rt"), m.cumulative().total_first_poll_delay, real.elapsed()
  );
}
rt feature: false first_poll_delay=38.75µs (real elapsed 40.084µs)
rt feature: true first_poll_delay=3600s (real elapsed 42.5µs)

开了 rt,首次 poll 延迟跟着虚拟时钟走,正好 3600 秒;不开,记的是真实流逝的 39 微秒。想在 start_paused 的测试里断言任务指标,要保留默认的 rt。另外,关掉 rt 并不能把 tokio 从依赖树里拿掉:本库无条件依赖 tokio-stream(为了 Stream trait),tokio-stream 依赖 tokio,cargo tree -i tokio 能看到这条路径。

lib.rs 用一个 cfg_rt! 宏把运行时部分整体包起来,同时加上 docs.rs 的 doc(cfg) 标注,写法和 tokio 自己的 cfg_rt! 一样(见 tokio 卷源码地图)。

tokio_unstable 不是 feature

运行时指标有一大半字段要 tokio_unstable,但它不在 [features] 里:它是传给 rustc 的 --cfg,原因和 tokio 一样,feature 会沿依赖树传递,--cfg 只能由构建最终程序的人自己设。tokio-metrics 在 Cargo.toml 第 18 行 用 check-cfg 登记了它,免得编译器对未知的 cfg 报警告。

最容易踩的是 docs.rs。[package.metadata.docs.rs] 写着:

[package.metadata.docs.rs]
all-features = true
# enable unstable features in the documentation
rustdoc-args = ["--cfg", "docsrs", "--cfg", "tokio_unstable"]
# it's necessary to _also_ pass `--cfg tokio_unstable` to rustc, or else
# dependencies will not be enabled, and the docs build will fail.
rustc-args = ["--cfg", "tokio_unstable"]

docs.rs 上的文档是带着 tokio_unstable 生成的,RuntimeMetrics 的 mean_poll_duration、total_steal_count 都列在那里。自己的项目不加 --cfg,这些字段根本不存在,编译报 E0609: no field ... on type RuntimeMetrics。这个坑见 05 常见坑的 no field mean_poll_duration on type RuntimeMetrics。

MSRV 与 CI

rust-version = "1.70.0"(第 5 行)。1.70 正是 OnceLock 稳定的版本,FutureMonitor 用到了它。不过 CI 里没有用 1.70 编译的任务:ci.yml 定义了 rust_min: 1.49.0 这个变量,没有任何任务引用它,所有任务都用 stable。CI 的测试矩阵跑两遍:一遍带 --cfg tokio_unstable,一遍不带,两遍都是 --all-features。

目录

部分内容
00 导读与全景本页;它解决什么问题
01 任务指标Instrumented 与 instrument_poll、包装 waker、TaskMetrics 与派生指标、TaskMonitorCore 与 FutureMonitor
02 运行时指标稳定与不稳定两层字段;Worker 差分与 poll 时间直方图
03 采样与导出intervals() 的差分;metrics-rs 与 metrique
04 设计取舍把各章的取舍汇总到一起
05 常见坑用报错原文或现象做标题
06 在本站中在本站上实测搜索重建、SSR 请求和 papers 流水线
07 动手从零写一个 Instrumented<F>

小结

  • 全卷锁定 tag v0.5.2(2d19218);main 上多出的排队延迟直方图不在本卷范围内。
  • 任务主线:instrument 包一层,每次 poll 记时间、换 waker,wake 时记唤醒时刻,切出空闲、排队、poll 三段。
  • 运行时主线:存一个 tokio 的 RuntimeMetrics,每次 next() 读累计值做差。
  • 两条线都是「写方只累加,读方做差」,intervals() 返回永不结束的 Iterator;任务那边第一次 next() 返回累计值,运行时那边从调用 intervals() 起算。
  • 默认 feature rt 打开运行时指标,也让任务指标用 tokio 的时钟(暂停时钟下跟着虚拟时间走);tokio_unstable 是 --cfg 不是 feature,docs.rs 带着它构建。

💬 关联讨论

发起讨论
加载中...