TaskMonitorCore 与 FutureMonitor
主线位置:任务主线的两个变体。
TaskMonitorCore改的是「指标记到哪里」:不经过Arc,直接记进一个static;FutureMonitor改的是「按什么聚合」:不按 monitor 聚合,一个 future 一份。
flowchart TB tm["TaskMonitor:Arc 包着 Core"] -->|Deref| core["TaskMonitorCore:RawMetrics + 开关"] st["static CORE"] --> core core --> inst["Instrumented T, M"] fm["FutureMonitor:私有的 TaskMonitor + SpanState"] -->|"instrument,消耗自己"| mf["MonitoredFuture"] mf --> inner["内层 Instrumented:poll、空闲、首次延迟"] root["外层任务的 Instrumented:publish_scheduling_delay"] -->|"poll 期间放进线程局部变量"| tl["CURRENT_SCHEDULING_LOG"] tl -->|"TaskScheduling::try_current"| mf mf -->|Ready| out["输出 Output 和 FutureMetrics"]
TaskMonitorCore:去掉 Arc
0.4.9 之前 TaskMonitor 里直接放着一个 Arc<RawMetrics>。#115(CHANGELOG 第 54 行:Expose a static-friendly TaskMonitorCore without inner Arc)把它拆成两层,第 522 行起:
#[derive(Clone, Debug)]
pub struct TaskMonitor {
base: Arc<TaskMonitorCore>,
}
impl Deref for TaskMonitor {
type Target = TaskMonitorCore;
// ...
}
// ...
#[derive(Debug)]
pub struct TaskMonitorCore {
metrics: RawMetrics,
record_scheduling_log: bool,
}
TaskMonitorCore 不是 Clone,构造函数全是 const fn:TaskMonitorCore::new 调 with_slow_poll_threshold,再调私有的 create,后者只是把 18 个 AtomicU64::new(0) 填进结构体。AtomicU64::new 和 Duration::from_micros 都是 const fn,于是整个 monitor 可以在编译期构造:
static BACKGROUND: TaskMonitorCore = TaskMonitorCore::new();
不需要 OnceLock、lazy_static,也没有堆分配。它的 instrument 要求 &'static self:
pub fn instrument<F>(&'static self, task: F) -> Instrumented<F, &'static Self> {
Self::instrument_with(task, self)
}
返回的 Instrumented 的第二个泛型参数是 &'static TaskMonitorCore,State 里存的就是这个引用,不再有 Arc 的引用计数操作。代价是上一页说的:Stream 只给 Instrumented<T, TaskMonitor> 实现了,用 static 包的流不能当 Stream 用。
不是 static 的情况用 instrument_with:只要 M: AsRef<TaskMonitorCore> + Send + Sync + 'static。它的文档举的例子是应用自己已经有一个 Arc<SharedState>,把 TaskMonitorCore 放进 SharedState,给 SharedState 的 Arc 包装实现 AsRef<TaskMonitorCore>,这样就不用「Arc 里再套一层 Arc」。TaskMonitor 自己也是这么接上的:它实现了 AsRef<TaskMonitorCore>,instrument 就是 instrument_with(task, self.clone())。
读的一方也有对应的写法。TaskMonitorCore::intervals 是关联函数而不是方法(第 2268 行),参数同样是 M: AsRef<TaskMonitorCore>,static 的写法是 TaskMonitorCore::intervals(&BACKGROUND),仓库的 examples/task.rs 就是这么用的。
builder 也分两套:TaskMonitorBuilder 的方法是 &mut self,build() 返回 TaskMonitor;TaskMonitorCoreBuilder 的方法全是按值传递的 const fn,可以在 static 的初始化表达式里链式调用。
FutureMonitor:一个 future 一份指标
TaskMonitor 按 monitor 聚合:一个路由一个 monitor,看到的是这个路由所有请求的总和。想把指标挂到单个请求的日志或 trace 上,就得一个请求一个 monitor。0.5.2 的 #127(CHANGELOG 第 14 行:add per-request task metrics via FutureMonitor)把这件事做成了现成的类型,FutureMonitor:
// Deliberately not `Clone`: cloning would share the underlying span and let two
// instrumented futures be measured as one, which is exactly the reuse `instrument`
// (by consuming `self`) is meant to prevent.
#[derive(Debug, Default)]
pub struct FutureMonitor {
monitor: TaskMonitor,
span: Arc<SpanState>,
}
impl FutureMonitor {
// ...
pub fn instrument<F>(self, task: F) -> MonitoredFuture<F> {
MonitoredFuture {
inner: self.monitor.instrument(task),
monitor: self.monitor,
span: self.span,
}
}
}
它里面就是一个私有的 TaskMonitor,专门给这一个 future 用。instrument 按值接收 self,用过一次就没了,也不能 clone,所以一个 FutureMonitor 只会量一个 future。每个 future 的成本是三次堆分配:TaskMonitor 的 Arc、SpanState 的 Arc、Instrumented 自己的 Arc<State>。
返回的 MonitoredFuture 的输出是一个二元组:
impl<F: Future> Future for MonitoredFuture<F> {
type Output = (F::Output, FutureMetrics);
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
// ...
this.span
.record_poll_start(TaskScheduling::try_current().unwrap_or_default());
let ret = this.inner.poll(cx);
// ...
this.span.record_poll_end();
match ret {
Poll::Ready(output) => {
let metrics = build_future_metrics(this.monitor, this.span);
Poll::Ready((output, metrics))
}
Poll::Pending => Poll::Pending,
}
}
}
指标只在 Ready 时交出来。请求被取消(future 在完成前被 drop)时,什么也拿不到;需要记录被取消的请求,得自己在外面处理。
FutureMetrics 的字段分两类,来源不同(build_future_metrics):
| 字段 | 来源 |
|---|---|
poll_count、total_poll_duration、slow_poll_count | 私有 TaskMonitor 的 cumulative() |
idle_count、total_idle_duration、max_idle_duration | 同上 |
first_poll_delay | 同上:从 FutureMonitor::instrument 被调用算起 |
total_duration | SpanState:第一次 poll 开始到最后一次 poll 结束 |
scheduled_count、total_scheduled_duration、long_delay_count | 外层任务的排队记录,在这个 future 存活期间的差值 |
前两类是内层 Instrumented 自己量的。排队那一类不用自己量的,原因就是上一页最后一个实验:嵌套的 Instrumented 量到的排队会多算外层在 poll 到它之前花的时间,真正的运行时排队只有被 tokio 直接调度的那个 future 看得到。
排队时间怎么传进来
外层任务要用一个打开了 publish_scheduling_delay 的 monitor 包装。打开之后,instrument_with 给这个任务的 State 分配一个 SchedulingLog:三个 AtomicU64(排队次数、排队总时长、长排队次数)。instrument_poll 每记一次排队,也往这个 log 里记一份(第 2997 行)。
然后在 poll 内层期间把它放进一个线程局部变量,第 1715 行:
thread_local! {
/// The scheduling log of the innermost [`Instrumented`] task currently being
/// polled on this thread, if any.
///
/// This is a hand-rolled task-local built on a `thread_local!`: it is only
/// set for the duration of a single synchronous poll (see
/// [`SchedulingLogGuard`]) and never held across an `.await`, so the usual
/// hazard of thread-locals in async code does not apply.
static CURRENT_SCHEDULING_LOG: RefCell<Option<Arc<SchedulingLog>>> =
const { RefCell::new(None) };
}
SchedulingLogGuard::enter 换进自己的 log、记下原来的值,Drop 时换回去,所以两层 Instrumented 都打开了开关时,内层 poll 完会把外层的 log 换回来。整个 poll 是同步的,不会跨 .await,用线程局部变量当「任务局部变量」是安全的:tokio 的 task_local! 也是这么做的,只是它要求在 future 外面套 scope。
FutureMonitor 这一侧,每次 poll 前调 TaskScheduling::try_current 读出当前值,交给 SpanState::record_poll_start:第一次 poll 记成「起点」,之后每次都覆盖「终点」。完成时 终点 - 起点(saturating_sub)就是这个 future 存活期间外层任务累积的排队。
没打开 publish_scheduling_delay,或者外层任务根本没被包装,try_current() 返回 None,unwrap_or_default() 给出全 0,三个排队字段就都是 0。不会报错,也不会有任何提示。
MonitoredFuture::poll 里有一段注释说内层 Instrumented 会在它的 poll 期间用自己的 log「遮住」外层的 log。实际上 FutureMonitor 里的私有 TaskMonitor 是用 TaskMonitor::default() 建的,没打开 publish_scheduling_delay,内层 State 的 log 是 None,什么也不会遮住。注释描述的情况在 0.5.2 里不会发生,不影响结果。
实验:打开与不打开
一个「请求」先在阻塞线程池上查 2ms「数据库」,再算 1ms;同一个单线程运行时上另放一个忙 5ms 的任务制造排队。外层任务分别用打开和没打开 publish_scheduling_delay 的 monitor 包装,各处理两个请求。顺便验证 static 的 TaskMonitorCore:
use std::time::{Duration, Instant};
use tokio_metrics::{FutureMonitor, TaskMonitor, TaskMonitorCore, TaskScheduling};
fn spin(d: Duration) {
let t = Instant::now();
while t.elapsed() < d {}
}
// const fn 构造,可以直接放进 static,不需要 OnceLock / lazy_static
static BACKGROUND: TaskMonitorCore = TaskMonitorCore::new();
// 一个「请求」:先在阻塞线程池上查一次「数据库」(2ms),再算 1ms
async fn handle(id: u32) -> u32 {
tokio::task::spawn_blocking(|| std::thread::sleep(Duration::from_millis(2))).await.unwrap();
spin(Duration::from_millis(1));
id
}
async fn serve(root: TaskMonitor, label: &'static str) {
let task = tokio::spawn(root.instrument(async move {
println!("{label}: try_current inside = {:?}", TaskScheduling::try_current().is_some());
for id in 0..2 {
let (out, m) = FutureMonitor::new().instrument(handle(id)).await;
println!(
"{label}: req {out} polls={} slow={} idle={:.1?} total={:.1?} scheduled={} sched={:.1?}",
m.poll_count, m.slow_poll_count, m.total_idle_duration, m.total_duration,
m.scheduled_count, m.total_scheduled_duration
);
}
}));
// 同一个线程上再放一个忙 5ms 的任务,制造排队
tokio::spawn(async { spin(Duration::from_millis(5)) });
task.await.unwrap();
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tokio::spawn(BACKGROUND.instrument(async { spin(Duration::from_micros(100)) })).await.unwrap();
println!("static core: polls={} slow={}", BACKGROUND.cumulative().total_poll_count, BACKGROUND.cumulative().total_slow_poll_count);
println!("outside any task: try_current = {:?}", TaskScheduling::try_current());
let mut b = TaskMonitor::builder();
b.publish_scheduling_delay();
serve(b.build(), "opt-in ").await;
serve(TaskMonitor::new(), "default").await;
}
依赖同前。M2 Max 笔记本、--release,连跑 5 次,计数完全相同,毫秒数的差别在 0.1ms 以内(req 1 的 sched 在 4.8–11.9µs 之间)。其中一次:
static core: polls=1 slow=1
outside any task: try_current = None
opt-in : try_current inside = true
opt-in : req 0 polls=2 slow=1 idle=2.6ms total=6.0ms scheduled=1 sched=2.5ms
opt-in : req 1 polls=2 slow=1 idle=2.5ms total=3.5ms scheduled=1 sched=4.8µs
default: try_current inside = false
default: req 0 polls=2 slow=1 idle=2.5ms total=6.0ms scheduled=0 sched=0.0ns
default: req 1 polls=2 slow=1 idle=2.5ms total=3.5ms scheduled=0 sched=0.0ns
static core:static的 monitor 和TaskMonitor一样记数,100µs 的 poll 记成慢 poll。try_current:任务外面是None;在打开了开关的任务里是Some,没打开的是None。req 0:spawn_blocking2ms 后从阻塞线程唤醒外层任务,但单线程正被那个 5ms 的任务占着,要再等约 2.5ms。打开时sched=2.5ms,idle 2.6 + poll 约 1 + 排队 2.5 ≈total6.0ms,三段对得上;不打开时排队是 0,total里有 2.5ms 不知去向。req 1:那个忙 5ms 的任务已经跑完,排队只有几微秒。同一个路由上的两个请求,一个排了 2.5ms,一个几乎没排,按 monitor 聚合的TaskMetrics只能给出两者的平均。这正是FutureMonitor要解决的。
poll 两次、slow=1:第一次 poll 只是把活交给阻塞线程池,很快;第二次 poll 忙了 1ms。
scheduled_count 记的是外层任务在这段时间里被调度的次数,不是这个 future 自己被 poll 的次数。字段文档举的例子是 select!:外层任务为了推进别的分支被唤醒、排队,这些排队也算在这个 future 头上,所以 scheduled_count 可以大于 poll_count。
小结
TaskMonitor是Arc<TaskMonitorCore>加Deref;TaskMonitorCore全部const fn构造,可以放进static,instrument要求&'static self,指标记进Instrumented<F, &'static TaskMonitorCore>。- 非
static的场合用instrument_with,任何AsRef<TaskMonitorCore>都能当记账的地方,省掉一层Arc。 FutureMonitor是一个只用一次的私有TaskMonitor;MonitoredFuture完成时输出(Output, FutureMetrics),取消时什么也不输出;每个 future 三次堆分配。- 排队指标只有最外层任务量得准:外层 monitor 打开
publish_scheduling_delay后,每次 poll 把排队记录放进线程局部变量,FutureMonitor在首次和最后一次 poll 前各读一次做差。没打开就是全 0,不报错。 - 实测打开后 idle + poll + 排队 ≈
total_duration;不打开时排队的那几毫秒从指标里消失。