Rust in Everything
基于 tokio-metrics 0.5.2

稳定与不稳定两层字段

主线位置:运行时主线的起点。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_countnum_workers无
live_tasks_countnum_alive_tasks无
global_queue_depthglobal_queue_depth无
total_park_count、max_park_count、min_park_countworker_park_count64 位原子
total_busy_duration、max_busy_duration、min_busy_durationworker_total_busy_duration64 位原子
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_countworker_poll_count每次 poll 任务前,start_poll 里 poll_count += 1worker 主循环
mean_poll_duration、mean_poll_duration_worker_min/maxworker_mean_poll_time一批任务处理完时更新的 EWMA,stats.rs 第 93 行全局注入队列
poll_time_histogrampoll_time_histogram_bucket_countpoll 结束时 end_poll 记一笔,要先在 Builder 上打开Builder、Runtime 与 Handle
total/max/min_noop_countworker_noop_countpark 前发现自上次 park 以来一个任务也没 poll,about_to_park窃取与空闲
total/max/min_steal_count、total/max/min_steal_operationsworker_steal_count、worker_steal_operations偷到一批时,queue.rs 第 446 行:前者加偷到的任务数,后者加 1窃取与空闲
num_remote_schedulesremote_schedule_count任务从运行时外面进注入队列,push_remote_task全局注入队列
total/max/min_local_schedule_countworker_local_schedule_countworker 线程上调度任务,schedule_local本地队列
total/max/min_overflow_countworker_overflow_count本地队列满了、把一半挪进注入队列,queue.rs 第 355 行本地队列
total/max/min_local_queue_depthworker_local_queue_depth瞬时值,读本地队列长度本地队列
blocking_queue_depth、blocking_threads_count、idle_blocking_threads_countblocking_queue_depth、num_blocking_threads、num_idle_blocking_threads瞬时值,读阻塞池阻塞线程池
budget_forced_yield_countbudget_forced_yield_count预算减到 0 的那一次,coop/mod.rs 第 355 行协作式调度预算
io_driver_ready_countio_driver_ready_countIO 驱动每轮把就绪事件数加上去,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 field mean_poll_duration on type RuntimeMetrics。
  • 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 时间直方图才每次多取两次时间。

💬 关联讨论

发起讨论
加载中...