稳定与不稳定两层字段
主线位置:运行时主线的起点。
RuntimeMonitor::new只存下 tokio 的RuntimeMetrics句柄;本页讲 tokio-metrics 的RuntimeMetrics有哪些字段、每个字段背后是 tokio 的哪个计数器、哪一段调度器代码在累加它。差分怎么做放在下一页。
flowchart TB rm["RuntimeMonitor:只存 tokio 的 RuntimeMetrics 句柄"] --> iv["intervals()"] mac["define_runtime_metrics! 宏"] --> st["stable 组:10 个字段,原样输出"] mac --> un["unstable 组:31 个字段,每个加 cfg(tokio_unstable)"] st --> s1["num_workers、num_alive_tasks、global_queue_depth"] st --> s2["worker_park_count、worker_total_busy_duration:要 64 位原子"] un --> u1["poll 次数与均值、直方图"] un --> u2["窃取、溢出、本地调度、远程调度"] un --> u3["阻塞池、预算强制让出、IO 就绪"] s1 --> tk["tokio 调度器里的计数器"] s2 --> tk u1 --> tk u2 --> tk u3 --> tk
RuntimeMonitor 只存一个句柄
RuntimeMonitor 只有一个字段:
#[derive(Debug)]
pub struct RuntimeMonitor {
/// Handle to the runtime
runtime: runtime::RuntimeMetrics,
}
RuntimeMonitor::new 调一次 handle.metrics() 就返回了。tokio 的 RuntimeMetrics 本身也只是一个 Handle,每个方法现读运行时内部的原子变量。所以「监控运行时」这件事里,tokio-metrics 不在运行时里装任何东西,没有回调、没有后台任务;数字全是 tokio 自己在调度器里攒的,tokio-metrics 只负责定期去读。任务指标那一侧要包 future、换 waker,运行时这一侧只是一个读者。
两个宏:字段在不开 cfg 时根本不存在
tokio-metrics 的 RuntimeMetrics 不是手写的结构体,而是 define_runtime_metrics! 生成的:
macro_rules! define_runtime_metrics {
(
stable {
$(
$(#[$($attributes:tt)*])*
$vis:vis $name:ident: $ty:ty
),*
$(,)?
}
unstable {
// ... 同上
}
) => {
/// Key runtime metrics.
#[non_exhaustive]
#[cfg_attr(feature = "metrique-integration", metrique::unit_of_work::metrics(subfield_owned))]
#[derive(Default, Debug, Clone)]
pub struct RuntimeMetrics {
$(
$(#[$($attributes)*])*
#[cfg_attr(docsrs, doc(cfg(feature = "rt")))]
$vis $name: $ty,
)*
$(
$(#[$($unstable_attributes)*])*
#[cfg(tokio_unstable)]
#[cfg_attr(docsrs, doc(cfg(all(feature = "rt", tokio_unstable))))]
$unstable_vis $unstable_name: $unstable_ty,
)*
}
};
}
宏做的事只有一件:unstable 组里的每个字段前面加 #[cfg(tokio_unstable)]。字段不是 Option,也不是填 0,而是在不开 cfg 时从结构体里消失。下面的实验数一下 RuntimeMetrics::default() 的 Debug 输出里有几个顶层字段:不开 cfg 是 10 个,开了是 41 个。
内部的 Worker 快照和 RuntimeIntervals 用同一个思路的第二个宏 define_semi_stable!:稳定的部分(worker 序号、park 次数、忙碌时长)原样保留,其余的上一次读数也只在 cfg 下存在。不开 cfg 时,tokio-metrics 连这些计数器都不会去读。
有一个例外:poll_time_histogram 字段本身在 unstable 组里,但它的类型 PollTimeHistogram 从 0.5.1 起不再加 cfg。runtime.rs 第 7 行的注释说明了原因:某些派生宏会把结构体重新展开一遍、把字段上的 cfg 弄丢(issue #128),这时字段的类型必须在不开 cfg 时也能解析。这是 CHANGELOG 0.5.1 的「make PollTimeHistogram available without tokio_unstable (#131)」。
稳定的 10 个字段
| 字段 | 读 tokio 的哪个方法 | tokio 一侧的门槛 |
|---|---|---|
workers_count | num_workers | 无 |
live_tasks_count | num_alive_tasks | 无 |
global_queue_depth | global_queue_depth | 无 |
total_park_count、max_park_count、min_park_count | worker_park_count | 64 位原子 |
total_busy_duration、max_busy_duration、min_busy_duration | worker_total_busy_duration | 64 位原子 |
elapsed | 不读 tokio,本库自己记的墙钟时间 | — |
后两行在 tokio 里包在 cfg_64bit_metrics! 里,条件是 target_has_atomic = "64",不是 tokio_unstable。tokio-metrics 无条件调用这两个方法,按源码推断,在没有 64 位原子的目标(一些 32 位嵌入式平台)上 rt feature 编译不过;这一点没有在这类目标上实测。
前三个是瞬时值:每次 next() 时读一次,不做差分。live_tasks_count 是 0.4.7 才挪进稳定组的(CHANGELOG:make live_tasks_count (num_alive_tasks()) stable,#108)。park 和 busy 是每个 worker 一份的累计值,tokio-metrics 对每个 worker 做差,再求和、取最大最小,所以一个 tokio 方法变成三个字段。
tokio 另有一个稳定方法 worker_park_unpark_count(park 加 unpark 的次数),tokio-metrics 0.5.2 没有取它。
不稳定的 31 个字段
剩下的字段全在 tokio 的 cfg_unstable_metrics! 后面。下表按「tokio 在哪里累加」排,最后一列是 tokio 卷里讲这段调度器代码的那一页:
| 字段 | tokio 方法 | 在哪里累加 | tokio 卷 |
|---|---|---|---|
total/max/min_polls_count | worker_poll_count | 每次 poll 任务前,start_poll 里 poll_count += 1 | worker 主循环 |
mean_poll_duration、mean_poll_duration_worker_min/max | worker_mean_poll_time | 一批任务处理完时更新的 EWMA,stats.rs 第 93 行 | 全局注入队列 |
poll_time_histogram | poll_time_histogram_bucket_count | poll 结束时 end_poll 记一笔,要先在 Builder 上打开 | Builder、Runtime 与 Handle |
total/max/min_noop_count | worker_noop_count | park 前发现自上次 park 以来一个任务也没 poll,about_to_park | 窃取与空闲 |
total/max/min_steal_count、total/max/min_steal_operations | worker_steal_count、worker_steal_operations | 偷到一批时,queue.rs 第 446 行:前者加偷到的任务数,后者加 1 | 窃取与空闲 |
num_remote_schedules | remote_schedule_count | 任务从运行时外面进注入队列,push_remote_task | 全局注入队列 |
total/max/min_local_schedule_count | worker_local_schedule_count | worker 线程上调度任务,schedule_local | 本地队列 |
total/max/min_overflow_count | worker_overflow_count | 本地队列满了、把一半挪进注入队列,queue.rs 第 355 行 | 本地队列 |
total/max/min_local_queue_depth | worker_local_queue_depth | 瞬时值,读本地队列长度 | 本地队列 |
blocking_queue_depth、blocking_threads_count、idle_blocking_threads_count | blocking_queue_depth、num_blocking_threads、num_idle_blocking_threads | 瞬时值,读阻塞池 | 阻塞线程池 |
budget_forced_yield_count | budget_forced_yield_count | 预算减到 0 的那一次,coop/mod.rs 第 355 行 | 协作式调度预算 |
io_driver_ready_count | io_driver_ready_count | IO 驱动每轮把就绪事件数加上去,io/driver.rs 第 257 行 | 反应器 |
数一下:3 + 4 + 3 + 6 + 1 + 3 + 3 + 3 + 3 + 1 + 1 = 31。RuntimeMetrics 上还有一个只在 cfg 下存在的派生方法 mean_polls_per_park,等于 poll 次数 ÷(park 次数 − noop 次数),分母为 0 时返回 0.0,不是 NaN。
tokio 1.54.1 的 unstable 指标里,这些没被 tokio-metrics 0.5.2 取用:spawned_tasks_count、worker_thread_id、IO 驱动的 fd 注册与注销计数,以及要再加 schedule-latency feature 的排队延迟直方图。最后一个在写作时的 main 分支上已经加进来了(#137,见怎么读这一卷),不在 0.5.2 里。
打开 tokio_unstable,tokio 每次 poll 多做什么
上表里的计数都先记在每个 worker 自己的 MetricsBatch 里(普通的 u64,不是原子变量),再在固定时机一次性写进共享的原子变量,时机放在下一页。MetricsBatch 的方法用 cfg_metrics_variant! 写两份:不开 cfg 时 start_poll、end_poll 是空函数;开了之后,start_poll 是这样:
pub(crate) fn start_poll(
&mut self,
schedule_latency_context: Option<ScheduleLatencyContext>,
) -> Option<Duration> {
self.poll_count += 1;
let poll_started_at = self.poll_timer.as_mut().map(|poll_timer| {
let now = Instant::now();
poll_timer.poll_started_at = now;
now
});
// ...
}
poll_timer 只有在 Builder 上打开了 poll 时间直方图时才是 Some。所以只开 tokio_unstable、不开直方图,每次 poll 多出来的就是一次线程私有的 += 1,不多取时间;打开直方图后,start_poll 和 end_poll 各取一次 Instant::now(),tokio 的文档也写着直方图「requires calling Instant::now() twice per task poll」。窃取、溢出、本地调度这些计数同样是批次里的 += 1。
实验:一个区间里各计数怎么变
在 4 个 worker 的运行时上做五件事,各自触发一组计数,然后取一次区间:
use std::time::{Duration, Instant};
use tokio_metrics::{RuntimeMetrics, RuntimeMonitor};
fn spin(d: Duration) {
let t = Instant::now();
while t.elapsed() < d {}
}
// 数 Debug 输出里的顶层字段
fn field_count(m: &RuntimeMetrics) -> usize {
format!("{m:#?}")
.lines()
.filter(|l| l.starts_with(" ") && !l.starts_with(" ") && l.contains(':'))
.count()
}
fn main() {
let mut b = tokio::runtime::Builder::new_multi_thread();
b.worker_threads(4).enable_all();
#[cfg(tokio_unstable)]
b.enable_metrics_poll_time_histogram();
let rt = b.build().unwrap();
let monitor = RuntimeMonitor::new(rt.handle());
let mut iv = monitor.intervals();
println!("tokio_unstable={} fields={}", cfg!(tokio_unstable), field_count(&RuntimeMetrics::default()));
// 1. 从运行时外面 spawn 100 个任务
let hs: Vec<_> = (0..100).map(|_| rt.spawn(async {})).collect();
rt.block_on(async move {
for h in hs {
h.await.unwrap();
}
// 2. 一个任务一口气 spawn 1000 个小任务(每个忙 20µs)
tokio::spawn(async {
let hs: Vec<_> = (0..1000).map(|_| tokio::spawn(async { spin(Duration::from_micros(20)) })).collect();
for h in hs {
h.await.unwrap();
}
})
.await
.unwrap();
// 3. 一个循环收完 1000 条已经就绪的消息
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
for i in 0..1000 {
tx.send(i).unwrap();
}
drop(tx);
tokio::spawn(async move { while rx.recv().await.is_some() {} }).await.unwrap();
// 4. 10 个回环 TCP 连接
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let acc = tokio::spawn(async move {
for _ in 0..10 {
l.accept().await.unwrap();
}
});
for _ in 0..10 {
tokio::net::TcpStream::connect(addr).await.unwrap();
}
acc.await.unwrap();
// 5. 3 个阻塞任务,取样时还在睡
for _ in 0..3 {
tokio::task::spawn_blocking(|| std::thread::sleep(Duration::from_millis(300)));
}
tokio::time::sleep(Duration::from_millis(50)).await;
});
let m = iv.next().unwrap();
println!(
"stable: workers={} live={} park total/max/min={}/{}/{} busy total={:?} busy_ratio={:.2} global_queue_depth={} elapsed={:?}",
m.workers_count, m.live_tasks_count, m.total_park_count, m.max_park_count, m.min_park_count,
m.total_busy_duration, m.busy_ratio(), m.global_queue_depth, m.elapsed
);
#[cfg(tokio_unstable)]
{
println!(
"polls total/max/min={}/{}/{} mean_poll={:?} worker_min/max={:?}/{:?} polls_per_park={:.1}",
m.total_polls_count, m.max_polls_count, m.min_polls_count, m.mean_poll_duration,
m.mean_poll_duration_worker_min, m.mean_poll_duration_worker_max, m.mean_polls_per_park()
);
println!(
"remote={} local_schedule={} overflow={} steal={} steal_ops={} noop={}",
m.num_remote_schedules, m.total_local_schedule_count, m.total_overflow_count,
m.total_steal_count, m.total_steal_operations, m.total_noop_count
);
println!(
"budget_forced_yield={} io_ready={} blocking threads/idle/queue={}/{}/{} local_queue_depth={}",
m.budget_forced_yield_count, m.io_driver_ready_count, m.blocking_threads_count,
m.idle_blocking_threads_count, m.blocking_queue_depth, m.total_local_queue_depth
);
let counts: Vec<String> = m.poll_time_histogram.buckets().iter().map(|b| b.count().to_string()).collect();
println!("histogram buckets={} counts=[{}]", m.poll_time_histogram.buckets().len(), counts.join(","));
}
}
依赖同前(tokio-metrics = "=0.5.2"、tokio = { version = "=1.54.1", features = ["full"] })。M2 Max 笔记本、--release,先不带 cfg 跑,再用 RUSTFLAGS="--cfg tokio_unstable" 跑,各 7 次。不带 cfg 的一次:
tokio_unstable=false fields=10
stable: workers=4 live=0 park total/max/min=42/21/3 busy total=20.830501ms busy_ratio=0.35 global_queue_depth=0 elapsed=58.920459ms
带 cfg 的一次:
tokio_unstable=true fields=41
stable: workers=4 live=0 park total/max/min=44/25/3 busy total=20.687664ms busy_ratio=0.35 global_queue_depth=0 elapsed=58.761916ms
polls total/max/min=1122/331/248 mean_poll=15.967µs worker_min/max=8.878µs/20.348µs polls_per_park=59.1
remote=103 local_schedule=1189 overflow=6 steal=52 steal_ops=5 noop=25
budget_forced_yield=10 io_ready=20 blocking threads/idle/queue=3/0/0 local_queue_depth=0
histogram buckets=10 counts=[1122,0,0,0,0,0,0,0,0,0]
7 次里稳定不变的:fields 10 和 41,remote=103,io_ready=20,阻塞池 3/0/0,直方图全落在第一个桶。会变的:poll 次数 1120–1124,本地调度 1149–1244,溢出 5–6,窃取 31–156(窃取操作 3–6 次),noop 20–25,预算强制让出 8–12,busy 总和 20.5–20.9ms。
fields=10和41:同一份源码,换个RUSTFLAGS结构体就多出 31 个字段。这也意味着不带 cfg 时,代码里写m.total_polls_count会编译失败(E0609),见 05 常见坑的 no fieldmean_poll_durationon typeRuntimeMetrics。busy total约 20.7ms:1000 个任务各忙 20µs 是 20ms,其余任务几乎不花时间。busy_ratio是 20.7ms ÷ 59ms,59ms 里大头是最后那 50ms 的sleep。remote=103:100 个来自运行时外的rt.spawn;另外 3 个是block_on里调的 3 次tokio::spawn。block_on跑在调用它的主线程上,主线程不是 worker,从这里 spawn 同样走注入队列,算远程调度。由 worker 上的任务 spawn 出来的 1000 个小任务算本地调度。overflow=5~6:本地队列 256 格,满了就把一半(128 个)挪进注入队列。1000 个任务一口气 spawn,同时还有别的 worker 在偷,溢出了五六次。budget_forced_yield:每个任务每次被 poll 有 128 的预算,收 1000 条消息大约要被打断 7 次;多出来的几次来自同一区间里别的await,没有逐一核实。io_ready=20:10 个连接,和「监听端可读 10 次、发起端可写 10 次」对得上。- 阻塞池
3/0/0:取样时 3 个阻塞线程都在睡,没有空闲线程,也没有排队的任务。这三个字段是取样那一刻的瞬时值。 mean_poll=16µs:任务明明各忙 20µs,均值却更小、各 worker 之间差一倍多。这个字段不是「本区间 poll 时长的均值」,下一页细讲。
小结
RuntimeMonitor只存 tokio 的RuntimeMetrics句柄;运行时一侧所有数字都是 tokio 在调度器里攒的,本库只读。define_runtime_metrics!给unstable组的每个字段加#[cfg(tokio_unstable)],不开 cfg 时字段不存在:实测 10 个和 41 个。PollTimeHistogram类型从 0.5.1 起不再加 cfg,只有字段加。- 稳定的 10 个字段:worker 数、存活任务数、全局队列深度、park 次数三件、忙碌时长三件、
elapsed;park 和忙碌在 tokio 里要求 64 位原子。 - 不稳定的 31 个字段各对应一处调度器代码:poll 前
poll_count += 1、偷到一批、本地队列溢出、从外部调度、预算耗尽、IO 驱动的就绪事件、阻塞池的瞬时状态。 - 只开
tokio_unstable时,tokio 每次 poll 多一次线程私有的+= 1;打开 poll 时间直方图才每次多取两次时间。