怎么读这一卷
主线位置:两条主线的总览。本页把任务主线和运行时主线各画一遍,用一个程序把两条线都跑一遍,再给出源码地图。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1["00 全景、01 TaskMetrics"]
use1 --> use2[03 采样与导出]
use2 --> use3[05 常见坑]
q -->|读懂实现| all[00 到 07 按顺序]
锁定版本
| 项 | 值 |
|---|---|
| crate | tokio-metrics 0.5.2 |
| tag | v0.5.2 |
| commit | 2d19218(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 与 Handle | poll 时间直方图要先在这里打开 |
tracing 卷的 Instrument 与异步 讲的 Instrumented 和本卷的 Instrumented 是同一个套路:包住 future,在每次 poll 的前后做一件事。tracing 做的是进出 span,本库做的是记时间;本库多了一步,换掉 waker。
源码地图
v0.5.2 的 src/ 一共 8 个文件、6 905 行,去掉注释和空行 2 361 行(含单元测试):
| 文件 | 行数 | 去掉注释 | 管什么 | 本卷 |
|---|---|---|---|---|
task.rs | 3 673 | 1 024 | TaskMonitor、TaskMonitorCore、Instrumented、TaskMetrics、FutureMonitor、TaskIntervals | 01、03 |
runtime.rs | 1 779 | 516 | RuntimeMonitor、RuntimeMetrics、RuntimeIntervals、Worker 快照 | 02、03 |
runtime/poll_time_histogram.rs | 212 | 156 | PollTimeHistogram、HistogramBucket | 02 |
metrics_rs.rs | 301 | 269 | 把字段按 Counter / Gauge / Histogram 分类的宏 | 03 |
task/metrics_rs_integration.rs | 312 | 113 | TaskMetricsReporterBuilder | 03 |
runtime/metrics_rs_integration.rs | 372 | 139 | RuntimeMetricsReporterBuilder | 03 |
lib.rs | 219 | 109 | crate 文档、cfg_rt!、pub use | 本页 |
derived_metrics.rs | 37 | 35 | derived_metrics! 宏 | TaskMetrics 与派生指标 |
task.rs 的 3 673 行里有 2 374 行是 /// 文档注释:TaskMetrics 的每个字段都带一段可运行的示例。任务主线真正的算法只有 instrument_poll 和 State::on_wake 两个函数,一共 170 行。仓库里另有 4 个 example、1 个 bench(poll_overhead,量包一层之后每次 poll 多花多少,04 设计取舍会用到)。
三个 feature
[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 带着它构建。