Rust in Everything
基于 tokio-metrics 0.5.2

包装 waker:空闲与排队怎么算

主线位置:任务主线上唯一的算法。上一页的 instrument_poll 在第二段读 woke_at、第三段换 waker;本页讲 woke_at 是谁、在什么时候写进去的,以及由此切出来的「空闲」和「排队」到底对应 tokio 里的哪段时间。

stateDiagram-v2
  direction LR
  Polling: 被 poll(poll 时长)
  Idle: 空闲(idled_at 到 woke_at)
  Queued: 排队(woke_at 到 poll_start)
  [*] --> Polling: 首次 poll
  Polling --> Idle: 返回 Pending,记 idled_at
  Idle --> Queued: 第一次 wake,记 woke_at
  Queued --> Queued: 再次 wake,不改 woke_at
  Queued --> Polling: 下次 poll,取出并清零 woke_at
  Polling --> Queued: poll 中途 wake
  Polling --> [*]: 返回 Ready

State 就是 waker

State 是 Instrumented 和它交出去的 waker 共享的那一份数据:

struct State<M> {
    /// Where metrics should be recorded
    monitor: M,

    /// Instant at which the task was instrumented. This is used to track the time to first poll.
    instrumented_at: Instant,

    /// The instant, tracked as nanoseconds since `instrumented_at`, at which the future
    /// was last woken.
    woke_at: AtomicU64,

    /// Waker to forward notifications to.
    waker: AtomicWaker,
    // ...
    log: Option<Arc<SchedulingLog>>,
}

它实现了 futures 的 ArcWake:任何 Arc<T: ArcWake> 都能用 futures_util::task::waker_ref 或 waker 变成标准的 Waker,省掉手写 RawWakerVTable。

impl<M: Send + Sync> ArcWake for State<M> {
    fn wake_by_ref(arc_self: &Arc<State<M>>) {
        arc_self.on_wake();
        arc_self.waker.wake();
    }

    fn wake(self: Arc<State<M>>) {
        self.on_wake();
        self.waker.wake();
    }
}

两个方法都是先记时间(on_wake),再转给 AtomicWaker 里存着的真正的 waker。on_wake:

impl<M> State<M> {
    fn on_wake(&self) {
        let woke_at: u64 = match self.instrumented_at.elapsed().as_nanos().try_into() {
            Ok(woke_at) => woke_at,
            // ...
            Err(_) => return,
        };

        // We don't actually care about the result
        let _ = self.woke_at.compare_exchange(0, woke_at, SeqCst, SeqCst);
    }
}

compare_exchange(0, now):只有 woke_at 还是 0 时才写进去。instrument_poll 每次 poll 开头 swap(0) 把它清零,所以两次 poll 之间不管被唤醒多少次,留下的都是第一次唤醒的时刻。0 被当作「没被唤醒过」:真正在 instrumented_at 那一纳秒被唤醒的情况不存在。

转发那一步还有一个细节。AtomicWaker::wake 的实现(futures-core 0.3.34)是先 take() 再 wake():存着的 waker 被取走了,直到下次 poll 重新 register 之前,再来的 wake 什么也转发不了。这和 compare_exchange(0, ..) 的效果一致:两次 poll 之间,只有第一次唤醒同时被记时间、被转给 tokio。tokio 那边本来也不需要第二次,任务已经在队列里了(tokio 用状态字里的 NOTIFIED 位做同样的去重,见 tokio 卷一个 usize 的状态机)。

三个时刻切出两段

instrument_poll 第 2934–3004 行:

    let woke_at = state.woke_at.swap(0, SeqCst);
    // The state of a future is *idling* in the interim between the instant
    // it completes a `poll`, and the instant it is next awoken.
    if *idled_at < woke_at {
        // increment the counter of how many idles occurred
        metrics.total_idled_count.fetch_add(1, SeqCst);

        // compute the duration of the idle
        let idle_ns = woke_at.saturating_sub(*idled_at);
        // ...
        metrics.local_max_idle_duration_ns.fetch_max(idle_ns, SeqCst);
        metrics.global_max_idle_duration_ns.fetch_max(idle_ns, SeqCst);
        // adjust the total elapsed time monitored tasks spent idling
        metrics.total_idle_duration_ns.fetch_add(idle_ns, SeqCst);
    }
    // if this task spent any time in the scheduled state after instrumentation,
    // and after first poll, `woke_at` will be greater than 0.
    if woke_at > 0 {
        // increment the counter of how many schedules occurred
        metrics.total_scheduled_count.fetch_add(1, SeqCst);
        // ...
        let woke_instant = instrumented_at
            .checked_add(Duration::from_nanos(woke_at))
            .unwrap_or(poll_start);
        // ...
        let scheduled_ns = poll_start
            .saturating_duration_since(woke_instant)
            // ...
        let (count_bucket, duration_bucket) = // was the scheduling delay long or short?
            if scheduled >= metrics.long_delay_threshold {
                (&metrics.total_long_delay_count, &metrics.total_long_delay_duration_ns)
            } else {
                (&metrics.total_short_delay_count, &metrics.total_short_delay_duration_ns)
            };
        // ...
    }
段条件时长计数器
空闲idled_at < woke_at:唤醒发生在上次 poll 结束之后woke_at - idled_attotal_idled_count、total_idle_duration、两个最大值
排队woke_at > 0:上次 poll 之后被唤醒过poll_start - woke_attotal_scheduled_count、total_scheduled_duration、长短两桶

两个 if 是独立的。被唤醒过就一定记一次排队;只有唤醒晚于上次 poll 结束,才同时记一次空闲。首次 poll 时 woke_at 是 0,两段都不记,那段等待算在首次 poll 延迟里。

排队这一段,tokio-metrics 量的是「wake() 被调用」到「Instrumented 被 poll」。tokio 里对应的是任务从被放进运行队列到被 worker 取出、调用 poll 的时间,见 tokio 卷 poll 与唤醒和 worker 主循环。两者大多数时候是一回事,但有三种情况对不上,下面的实验逐个跑出来。

实验:四种唤醒

程序跑在单线程运行时上,方便控制谁在什么时候占着线程:

tokio-metrics = "=0.5.2"
tokio = { version = "=1.54.1", features = ["full"] }
use std::future::{poll_fn, Future};
use std::pin::pin;
use std::task::Poll;
use std::time::{Duration, Instant};
use tokio_metrics::{TaskMetrics, TaskMonitor};

fn spin(d: Duration) {
  let t = Instant::now();
  while t.elapsed() < d {}
}

fn show(name: &str, m: &TaskMetrics) {
  println!(
    "{name:<10} polls={} idled={} idle={:>7.1?} scheduled={} sched={:>7.1?}",
    m.total_poll_count, m.total_idled_count, m.total_idle_duration,
    m.total_scheduled_count, m.total_scheduled_duration
  );
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  // A. 两次 poll 之间被唤醒两次:10ms 一次,40ms 一次;线程被占到 50ms
  let a = TaskMonitor::new();
  let task = tokio::spawn(a.instrument(async {
    let mut started = false;
    poll_fn(|cx| {
      if started {
        return Poll::Ready(());
      }
      started = true;
      let waker = cx.waker().clone();
      std::thread::spawn(move || {
        std::thread::sleep(Duration::from_millis(10));
        waker.wake_by_ref(); // 第一次唤醒
        std::thread::sleep(Duration::from_millis(30));
        waker.wake(); // 第二次唤醒
      });
      Poll::Pending
    })
    .await
  }));
  tokio::task::yield_now().await; // 让 A 先跑第一次 poll
  spin(Duration::from_millis(50)); // 占住唯一的线程
  task.await.unwrap();
  show("two wakes", &a.cumulative());

  // B. poll 里面立刻 wake_by_ref 自己,然后返回 Pending
  let b = TaskMonitor::new();
  tokio::spawn(b.instrument(async {
    let mut yielded = false;
    poll_fn(|cx| {
      if yielded {
        return Poll::Ready(());
      }
      yielded = true;
      cx.waker().wake_by_ref();
      spin(Duration::from_millis(5)); // 唤醒之后,这次 poll 还要忙 5ms
      Poll::Pending
    })
    .await
  }))
  .await
  .unwrap();
  show("self wake", &b.cumulative());

  // C. tokio::task::yield_now:tokio 把唤醒推迟到这次 poll 之后
  let c = TaskMonitor::new();
  tokio::spawn(c.instrument(async {
    spin(Duration::from_millis(5));
    tokio::task::yield_now().await;
  }))
  .await
  .unwrap();
  show("yield_now", &c.cumulative());

  // D. 嵌套:外层每次 poll 先忙 5ms,再 poll 内层
  let outer = TaskMonitor::new();
  let inner = TaskMonitor::new();
  let inner_fut = inner.instrument(tokio::time::sleep(Duration::from_millis(10)));
  let task = tokio::spawn(outer.instrument(async move {
    let mut inner_fut = pin!(inner_fut);
    poll_fn(|cx| {
      spin(Duration::from_millis(5));
      inner_fut.as_mut().poll(cx)
    })
    .await
  }));
  tokio::task::yield_now().await;
  spin(Duration::from_millis(30)); // 定时器到期后,外层任务还要再等一会儿才被 poll
  task.await.unwrap();
  show("outer", &outer.cumulative());
  show("inner", &inner.cumulative());
}

M2 Max 笔记本、--release,连跑 3 次,计数完全相同,时长只在 A 的两列有零点几毫秒的差别(thread::sleep 的误差)。其中一次:

two wakes  polls=2 idled=1 idle= 12.5ms scheduled=1 sched= 37.5ms
self wake  polls=2 idled=0 idle=  0.0ns scheduled=1 sched=  5.0ms
yield_now  polls=2 idled=1 idle= 14.3µs scheduled=1 sched=334.0ns
outer      polls=2 idled=1 idle= 30.0ms scheduled=1 sched=542.0ns
inner      polls=2 idled=1 idle= 30.0ms scheduled=1 sched=  5.0ms

A:只记第一次唤醒

空闲 12.5ms + 排队 37.5ms = 50ms,正好是主任务占住线程的时长。排队从第一次唤醒(约 10ms 处)算起;如果记的是第二次唤醒(约 40ms 处),排队应该只有 10ms 左右。另外两次运行是 11.3 + 38.7 和 12.5 + 37.5,和都是 50ms。

这和 tokio 的行为一致:第一次唤醒时任务就进了队列,第二次唤醒对 tokio 来说什么也没改变。

B:poll 中途唤醒自己

idled=0,sched=5.0ms。唤醒发生在 poll 进行中,woke_at 比随后写入的 idled_at 早,idled_at < woke_at 不成立,不记空闲;woke_at > 0,记一次排队,时长从唤醒那一刻算起,包括这次 poll 剩下的 5ms。

tokio 那边,任务在运行时被唤醒只是置上 NOTIFIED 位,等这次 poll 返回后才重新进队列(tokio 卷 poll 与唤醒「运行时被唤醒:留记号,poll 完再说」一节)。所以 tokio-metrics 报的排队比任务真正在队列里待的时间多了「唤醒之后、poll 返回之前」那一段。一个 poll 里先唤醒自己再做重活,就会表现为排队变长,而不是 poll 变慢;当然,poll 时长那一列同样记下了这 5ms。

C:yield_now 被记成空闲

yield_now 的语义是「让出一次,马上再跑」,按理是一次排队。结果却是一次空闲(14µs)加上几乎为 0 的排队。原因在 tokio 1.54.1 的 yield_now:

        // Don't wake the task immediately, as that would push it right back
        // onto the run queue and it could be polled again before other tasks
        // or the IO/timer driver get a chance to run. Instead, hand the waker
        // to the scheduler, which wakes deferred tasks only after it has run
        // out of ready tasks and polled the driver. When polled from outside
        // a Tokio runtime, the waker is woken immediately.
        context::defer(cx.waker());

waker 交给调度器的 defer 列表,调度器跑完手头的任务、查过一次驱动之后才调 wake。那时 Instrumented 早已 poll 结束,idled_at < woke_at,于是记成空闲。协作式预算用完时 tokio 走的是同一条 defer 路径(见 tokio 卷协作式调度预算),也一样。

这里只有一个任务,看不出代价。它解决什么问题那个实验里,如果把 victim 改成 yield_now().await(其余不变),连跑 3 次:

victm wall=13.5ms poll=178.0ns idle=13.5ms sched= 4.0µs  slow_polls=0 long_delays=0
victm wall=13.5ms poll=237.0ns idle=13.5ms sched= 4.6µs  slow_polls=0 long_delays=0
victm wall=13.5ms poll=279.0ns idle=13.5ms sched= 5.2µs  slow_polls=0 long_delays=0

同样是被 cpu 任务挡了 13.5ms,用 spawn_blocking 唤醒时记在排队(sched=13.5ms),用 yield_now 时全记在空闲,long_delay_ratio 为 0。所以:主动让出、预算耗尽造成的等待,在本库的指标里是空闲,不是排队。 看到「空闲很长、外部依赖却很快」时,要想到这一条。

D:嵌套的 Instrumented

外层和内层的空闲都是 30ms:定时器 10ms 就到期了,但主任务在忙等,驱动直到 30ms 才有机会处理时间轮、调 wake。这是第三种对不上的情况:woke_at 记的是 wake() 被调用的时刻,事件发生后、驱动处理之前的延误算进空闲。

两者的排队不同:外层几乎为 0,内层是 5ms。唤醒沿着 waker 链传上去:定时器调内层 State 的 wake,内层记下时刻再转给外层 State,外层记下同一时刻再转给 tokio。两层的 woke_at 几乎相同,但内层的 poll_start 要等外层 poll 开始、先忙完 5ms 才轮到。所以嵌套的 Instrumented 量到的排队 = 外层的排队 + 外层这次 poll 在轮到它之前花的时间。真正的「运行时排队」只有最外层(被 tokio 直接调度的那个 future)看得到。FutureMonitor 正是为此不用自己量的排队,而是去读外层任务记下的值,见 TaskMonitorCore 与 FutureMonitor。

小结

  • Instrumented 把 Arc<State> 当 waker 交给内层;State 实现 ArcWake,wake 先记时间再转给 AtomicWaker 里存的真正 waker。
  • on_wake 用 compare_exchange(0, now),每次 poll 开头 swap(0):两次 poll 之间只记第一次唤醒;AtomicWaker::wake 先 take,第二次唤醒也不再转发。
  • 被唤醒过就记一次排队(poll_start - woke_at);唤醒晚于上次 poll 结束才记一次空闲(woke_at - idled_at)。
  • 三种对不上的情况:poll 中途唤醒自己,排队包含这次 poll 剩下的时间;yield_now 和预算耗尽走 tokio 的 defer,等待记成空闲;定时器、IO 的事件到 wake() 之间的驱动延误也算空闲。
  • 嵌套的 Instrumented 量到的排队会多算外层在 poll 到它之前花的时间;运行时排队只有最外层量得准。

💬 关联讨论

发起讨论
加载中...