Rust in Everything
基于 tokio-metrics 0.5.2

Instrumented 与 instrument_poll

主线位置:任务主线的起点和每一次 poll。instrument 是包装的那一刻,instrument_poll 是之后每次 poll 都要经过的那一层。

flowchart TB
  s["poll_start = now()"] --> first{第一次 poll?}
  first -->|是| fp["记首次 poll 延迟:poll_start 减 instrumented_at"]
  first -->|否| wake
  fp --> wake["取出并清零 woke_at"]
  wake --> idle["算空闲、排队(下一页)"]
  idle --> reg["存下调用方的 waker,换成 State"]
  reg --> ps["inner_poll_start = now()"]
  ps --> inner["poll 内层 future"]
  inner --> pe["inner_poll_end = now()"]
  pe --> idl["idled_at = 这一刻"]
  idl --> bucket{"耗时 >= slow_poll_threshold?"}
  bucket -->|是| slow[慢 poll 桶]
  bucket -->|否| fast[快 poll 桶]

instrument:包装的那一刻

TaskMonitor::instrument 只是把自己 clone 一份(一个 Arc),转给 TaskMonitorCore::instrument_with:

    pub fn instrument_with<F, M: AsRef<TaskMonitorCore> + Send + Sync + 'static>(
        task: F,
        monitor: M,
    ) -> Instrumented<F, M> {
        monitor
            .as_ref()
            .metrics
            .instrumented_count
            .fetch_add(1, SeqCst);

        let log = monitor
            .as_ref()
            .record_scheduling_log
            .then(|| Arc::new(SchedulingLog::default()));

        let state: State<M> = State {
            monitor,
            instrumented_at: Instant::now(),
            woke_at: AtomicU64::new(0),
            waker: AtomicWaker::new(),
            log,
        };

        let instrumented: Instrumented<F, M> = Instrumented {
            task,
            did_poll_once: false,
            idled_at: 0,
            state: Arc::new(state),
        };

        instrumented
    }

三件事:instrumented_count 加 1;记下 instrumented_at;建一个 Arc<State>,它将来既是指标的去处,也是交给内层 future 的 waker。log 只在 monitor 打开了 publish_scheduling_delay 时才分配,给 FutureMonitor 用,平时是 None。

所以每包一个 future 有一次堆分配(Arc<State>),TaskMonitor 自己的 clone 只是引用计数加 1。

Instrumented 本身用 pin_project! 声明,四个字段:

pin_project! {
    #[derive(Debug)]
    pub struct Instrumented<T, M: AsRef<TaskMonitorCore> = TaskMonitor> {
        // The task being instrumented
        #[pin]
        task: T,

        // True when the task is polled for the first time
        did_poll_once: bool,

        // The instant, tracked as nanoseconds since `instrumented_at`, at which the future finished
        // its last poll.
        idled_at: u64,

        // State shared between the task and its instrumented waker.
        state: Arc<State<M>>,
    }

    impl<T, M: AsRef<TaskMonitorCore>> PinnedDrop for Instrumented<T, M> {
        fn drop(this: Pin<&mut Self>) {
            this.state.monitor.as_ref().metrics.dropped_count.fetch_add(1, SeqCst);
        }
    }
}
字段谁读写作用
taskpoll 时被包住的 future,结构性 pin
did_poll_once只有 poll 路径第一次 poll 时记首次延迟,之后不再记
idled_at只有 poll 路径上次 poll 结束的时刻,存成「距 instrumented_at 的纳秒数」
statepoll 路径和 waker 两边monitor、instrumented_at、woke_at、调用方的 waker

idled_at 不需要原子:只有 poll 它的那个线程会读写。woke_at 在 State 里,是 AtomicU64,因为 wake() 可能在任何线程上被调用。两者都存成相对 instrumented_at 的纳秒数,而不是 Instant:Instant 不能原子地存取,换成 u64 纳秒就可以,而且两边用同一种编码,可以直接比大小。u64 纳秒能表示 584 年,on_wake 在转换失败时直接放弃记录,注释说这得是任务跑了 500 多年,还向 500 年后读到这段代码的人道了歉。

第二个泛型参数 M 是「指标记到哪里」:默认是 TaskMonitor,也可以是 &'static TaskMonitorCore 或任何 AsRef<TaskMonitorCore>,见 TaskMonitorCore 与 FutureMonitor。

PinnedDrop 在 future 被 drop 时把 dropped_count 加 1,不管它有没有完成、有没有被 poll 过。一个被 spawn 的任务完成后,tokio 会立刻 drop 它的 future,所以 instrumented_count - dropped_count 就是还活着的任务数,见 TaskMetrics 与派生指标。

instrument_poll:逐段走一遍

Future for Instrumented 和 Stream for Instrumented 都只有一行,把 Future::poll 或 Stream::poll_next 当作参数传给同一个函数 instrument_poll:

fn instrument_poll<T, M: AsRef<TaskMonitorCore> + Send + Sync + 'static, Out>(
    cx: &mut Context<'_>,
    instrumented: Pin<&mut Instrumented<T, M>>,
    poll_fn: impl FnOnce(Pin<&mut T>, &mut Context<'_>) -> Poll<Out>,
) -> Poll<Out> {

连签名一共 146 行,分五段。

第一段:首次 poll 延迟

    let poll_start = Instant::now();
    let this = instrumented.project();
    let idled_at = this.idled_at;
    let state = this.state;
    let instrumented_at = state.instrumented_at;
    let metrics = &state.monitor.as_ref().metrics;
    // ...
    if !*this.did_poll_once {
        // ...
        *this.did_poll_once = true;
        // ...
        let elapsed = poll_start
            .saturating_duration_since(instrumented_at)
            .as_nanos()
            .try_into()
            .unwrap_or(u64::MAX);
        // add this duration to `time_to_first_poll_ns_total`
        metrics.total_first_poll_delay_ns.fetch_add(elapsed, SeqCst);
        // ...
        metrics.first_poll_count.fetch_add(1, SeqCst);
    }

第 2905 行取第一次时间。首次 poll 延迟 = 这一刻减去 instrumented_at,也就是从 instrument() 被调用算起,不是从 tokio::spawn 算起。平时两者写在同一行,差别是几十微秒;如果先 instrument 好一批 future、过一会儿再 spawn,中间的时间全算进去。

use std::time::Duration;
use futures::StreamExt;
use tokio_metrics::TaskMonitor;

#[tokio::main]
async fn main() {
  // 1. instrument() 和 spawn 之间隔 200ms
  let early = TaskMonitor::new();
  let fut = early.instrument(async {});
  tokio::time::sleep(Duration::from_millis(200)).await;
  tokio::spawn(fut).await.unwrap();
  // 2. 在 spawn 的同一行 instrument
  let late = TaskMonitor::new();
  tokio::time::sleep(Duration::from_millis(200)).await;
  tokio::spawn(late.instrument(async {})).await.unwrap();
  println!("instrument early: first_poll_delay={:?}", early.cumulative().total_first_poll_delay);
  println!("instrument late:  first_poll_delay={:?}", late.cumulative().total_first_poll_delay);

  // 3. 包一个 Stream:每次 poll_next 都算一次 poll
  let streams = TaskMonitor::new();
  let items: Vec<u32> = streams
    .instrument(futures::stream::iter([1, 2, 3]).then(|x| async move {
      tokio::task::yield_now().await;
      x
    }))
    .collect()
    .await;
  let s = streams.cumulative();
  println!(
    "stream: items={items:?} instrumented={} first_polls={} polls={} dropped={}",
    s.instrumented_count, s.first_poll_count, s.total_poll_count, s.dropped_count
  );
}

依赖是 tokio-metrics = "=0.5.2"、tokio = { version = "=1.54.1", features = ["full"] }、futures = "0.3"。M2 Max 笔记本上连跑 3 次:

instrument early: first_poll_delay=202.161792ms
instrument late:  first_poll_delay=12.292µs
stream: items=[1, 2, 3] instrumented=1 first_polls=1 polls=7 dropped=1

后两次的第一行是 202.05ms、202.22ms,第二行是 30µs、45µs,第三行完全相同。200ms 之外多出的 2ms 是 sleep 本身的误差(定时器精度 1 毫秒,加上驱动处理到期的时机)。Stream 那一行在下面讲。

第二段:空闲与排队

    let woke_at = state.woke_at.swap(0, SeqCst);
    // ...
    if *idled_at < woke_at {
        // ... 记一次空闲:woke_at - idled_at
    }
    // ...
    if woke_at > 0 {
        // ... 记一次排队:poll_start - woke_at,按 long_delay_threshold 分长短
    }

第 2934 行把上次 poll 之后记下的唤醒时刻取出来并清零。三个时刻(idled_at、woke_at、poll_start)切出两段:上次 poll 结束到被唤醒是空闲,被唤醒到这次 poll 开始是排队。这一段是任务主线上唯一的算法,边界情况很多,放在下一页包装 waker。

第三段:换 waker

    // Register the waker
    state.waker.register(cx.waker());
    // Get the instrumented waker
    let waker_ref = futures_util::task::waker_ref(state);
    let mut cx = Context::from_waker(&waker_ref);

第 3006 行把调用方传进来的 waker(通常是 tokio 任务的 waker)存进 State 里的 AtomicWaker,然后用 State 自己造一个新的 Context。waker_ref 不增加 Arc 的引用计数,只是借用;内层 future 如果要把 waker 存起来(几乎所有叶子 future 都会),clone 时才会给 Arc<State> 加计数。

每次 poll 都要重新 register:Future 的约定是唤醒最近一次 poll 传进来的 waker,而调用方不一定每次传同一个(比如外面包着 FuturesUnordered 这类组合子,或者 future 被挪到了另一个执行器上)。tokio 直接 poll 任务时,同一个任务每次传的是同一个 waker。register 内部会用 will_wake 和上一次存的比较,是同一个就不 clone、不替换。

第四段:poll 内层

    let inner_poll_start = Instant::now();
    // ...
    let ret = if let Some(log) = &state.log {
        let _sched_guard = SchedulingLogGuard::enter(log.clone());
        poll_fn(this.task, &mut cx)
    } else {
        poll_fn(this.task, &mut cx)
    };
    let inner_poll_end = Instant::now();

第二次和第三次取时间夹住内层的 poll。log 那个分支只在 publish_scheduling_delay 打开时走,在 poll 期间把本任务的排队记录放进线程局部变量,给里面的 FutureMonitor 读;源码注释说,不打开时这条路径「byte-for-byte」和原来一样。

注意 poll 时长只算内层:inner_poll_end - inner_poll_start,不含前面记账和换 waker 的时间。包装本身的开销不会让 poll 显得更慢,但它确实花在了这次 poll 里,设计取舍的「实验:每次 poll 多花多少」量了它。

第五段:空闲开始、按阈值分桶

    /* idle time starts now */
    *idled_at = inner_poll_end
        .saturating_duration_since(instrumented_at)
        .as_nanos()
        .try_into()
        .unwrap_or(u64::MAX);
    /* accounting for poll time */
    let inner_poll_duration = inner_poll_end.saturating_duration_since(inner_poll_start);
    // ...
    let (count_bucket, duration_bucket) = // was this a slow or fast poll?
            if inner_poll_duration >= metrics.slow_poll_threshold {
                (&metrics.total_slow_poll_count, &metrics.total_slow_poll_duration)
            } else {
                (&metrics.total_fast_poll_count, &metrics.total_fast_poll_duration_ns)
            };
    // update the appropriate bucket
    count_bucket.fetch_add(1, SeqCst);
    duration_bucket.fetch_add(inner_poll_ns, SeqCst);
    ret

第 3024 行把这次 poll 结束的时刻记进 idled_at。不管内层返回 Ready 还是 Pending 都记;返回 Ready 的话这个值不会再被用到。

poll 时长用 >= 和阈值比较,落进快、慢两个桶之一。没有单独的「总 poll 次数」计数器,总数是读的时候两桶相加得到的。

一次 poll 的成本

数一下 instrument_poll 在一次「被唤醒过的普通 poll」上做的事:

项次数
Instant::now()3:poll_start、inner_poll_start、inner_poll_end
SeqCst 原子操作11:woke_at.swap 1,空闲 4(次数、区间最大、全局最大、总时长),排队 4(次数、长短桶两个、总时长),快慢桶 2
AtomicWaker::register1,内部还有自己的原子操作

第一次 poll 另加 2 次原子操作;打开 publish_scheduling_delay 时再加 2–3 次,外加一次 Arc clone 和线程局部变量的读写。另外,每次 wake() 还有一次 Instant::now()(instrumented_at.elapsed())和一次 compare_exchange。

这些原子变量都在同一个 monitor 的 RawMetrics 里,所有被它包住的任务、所有 worker 线程都在往同一组缓存行上写。monitor 分得越粗,争用越多。

Stream 也能包,但只限默认 monitor

instrument 不要求 F: Future,所以它也能包 Stream。上面实验的第三行:一个三个元素的流,每个元素前 yield_now 一次,collect 的过程中 poll_next 被调了 7 次:3 个元素各 2 次(一次 Pending、一次 Ready(Some)),最后一次 Ready(None)。instrumented_count 和 first_poll_count 都是 1:整个流算一个「任务」。

但 Stream 的实现写的是 impl<T: Stream> Stream for Instrumented<T>,没有第二个泛型参数,等于只给 Instrumented<T, TaskMonitor> 实现了。用静态的 TaskMonitorCore 包出来的是 Instrumented<T, &'static TaskMonitorCore>,只能当 Future 用:

use tokio_metrics::TaskMonitorCore;
static CORE: TaskMonitorCore = TaskMonitorCore::new();

fn main() {
  let s = CORE.instrument(futures::stream::iter([1, 2]));
  use futures::StreamExt;
  let _ = s.collect::<Vec<_>>();
}
error[E0599]: the method `collect` exists for struct `Instrumented<futures::stream::Iter<std::array::IntoIter<{integer}, 2>>, &TaskMonitorCore>`, but its trait bounds were not satisfied
   --> src/main.rs:7:13
    |
  7 |     let _ = s.collect::<Vec<_>>();
    |               ^^^^^^^ method cannot be called due to unsatisfied trait bounds
    |
   ::: pin-project-lite-0.2.17/src/lib.rs:745:9
    |
745 | /         $vis struct $ident $($def_generics)*
...
    | |_________- doesn't satisfy `_: Iterator`, `_: StreamExt` or `_: Stream`
    |
    = note: the following trait bounds were not satisfied:
            `Instrumented<futures::stream::Iter<std::array::IntoIter<{integer}, 2>>, &TaskMonitorCore>: Stream`
            which is required by `Instrumented<futures::stream::Iter<std::array::IntoIter<{integer}, 2>>, &TaskMonitorCore>: StreamExt`
            ...

(rustc 1.99.0。以三个冒号开头的那一行指向 pin_project! 宏展开出来的结构体定义,原文是注册表里的绝对路径,这里只留 crate 名;... 处删掉了几行宏源码和三条类似的 note。)

Future 的实现是泛型的,Stream 的不是;instrument_poll 本身对 M 是泛型的,给 Stream 的 impl 加上同样的 M 约束在技术上没有障碍,源码和 CHANGELOG 都没有说明为什么没加。0.5.2 里要包 Stream 就用 TaskMonitor。这条报错和两种修法收在 05 常见坑的 E0599 那一页。

同一个 future 包两次

instrument 不检查 future 是不是已经被包过。TaskMonitor::instrument 的文档里有两个例子:

  • 两个不同的 monitor 包同一个 future(monitor_a.instrument(monitor_b.instrument(fut))),两边各记一次,各自完整。这是合理用法:一个按路由聚合,一个按服务聚合。
  • 同一个 monitor 包两次,first_poll_count 变成 2,文档说「probably undesirable」。两层都会算 poll 时长,同一次 poll 被记两遍。

外层和内层量到的排队时间不完全相同,内层多算了外层在 poll 到它之前花的时间,见下一页最后一个实验。

小结

  • instrument 做三件事:instrumented_count 加 1、记下 instrumented_at、建一个 Arc<State>;每包一个 future 一次堆分配。
  • Instrumented 四个字段:内层 future、did_poll_once、idled_at(相对包装时刻的纳秒数),Arc<State>;drop 时 dropped_count 加 1,不管完成与否。
  • instrument_poll 五段:首次 poll 延迟、空闲与排队、换 waker、poll 内层、按阈值分快慢桶。首次延迟从 instrument() 算起,实测先包后等 200ms 记为 202ms。
  • 一次被唤醒过的 poll:3 次 Instant::now()、11 次 SeqCst 原子操作、1 次 AtomicWaker::register,全部写在同一个 monitor 共享的计数器上。
  • Stream 只给 Instrumented<T, TaskMonitor> 实现;用静态 TaskMonitorCore 包的流不能当 Stream 用。

💬 关联讨论

发起讨论
加载中...