Rust in Everything
基于 tokio-metrics 0.5.2

从零写一个 Instrumented

主线位置:任务主线的全部,缩到最小。instrument 包一层、每次 poll 记时间、包装 waker 记唤醒时刻、读的一方做差,四步各写一遍。

sequenceDiagram
  participant T as tokio 任务
  participant I as Instrumented
  participant W as WakeState
  participant F as 内层 future
  T->>I: poll(cx)
  I->>W: swap(0) 取出 woke_at,记一次排队
  I->>W: 存下 cx.waker()
  I->>F: 用 WakeState 做 waker 去 poll
  F-->>I: Pending
  I->>I: 记 poll 时长,超阈值记慢 poll
  F->>W: 将来 wake()
  W->>W: compare_exchange(0, now)
  W->>T: 转给真正的 waker

目标:不依赖 tokio-metrics,只用标准库,量出三样东西:poll 次数、poll 时长(按阈值分快慢)、排队时长(被唤醒到下一次 poll 开始)。空闲、首次 poll 延迟、长短排队两桶都省掉,最后列一张表说明省掉了什么。依赖只有 tokio(运行演示)和 tokio-metrics(只在演示里做对照,库本身不用它):

[dependencies]
tokio = { version = "=1.54.1", features = ["full"] }
# 只用来对照,mini 本身不依赖它
tokio-metrics = "=0.5.2"

写的一方:只累加

use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Wake, Waker};
use std::time::{Duration, Instant};

/// 写的一方:只累加。
#[derive(Default)]
struct Counters {
  polls: AtomicU64,
  poll_ns: AtomicU64,
  slow_polls: AtomicU64,
  scheduled: AtomicU64,
  scheduled_ns: AtomicU64,
}

struct Shared {
  counters: Counters,
  epoch: Instant,
  slow: Duration,
}

impl Shared {
  /// 相对 epoch 的纳秒数,加 1 让 0 专门表示「没被唤醒」。
  fn now_ns(&self) -> u64 {
    self.epoch.elapsed().as_nanos() as u64 + 1
  }
}

和 tokio-metrics 的 RawMetrics 一样,所有计数只增不减,写路径不清零、不加锁(TaskMetrics 与派生指标)。唤醒时刻要放进一个 AtomicU64,所以存成相对某个起点的纳秒数;tokio-metrics 的起点是每个任务的 instrumented_at,这里图省事用整个 monitor 的创建时刻。+ 1 是为了让 0 专门表示「没被唤醒」。

包装 waker:只记第一次唤醒

/// 包装 waker:记下第一次唤醒的时刻,再转给真正的 waker。
struct WakeState {
  woke_at: AtomicU64,
  real: Mutex<Option<Waker>>,
  shared: Arc<Shared>,
}

impl Wake for WakeState {
  fn wake(self: Arc<Self>) {
    self.wake_by_ref();
  }

  fn wake_by_ref(self: &Arc<Self>) {
    let now = self.shared.now_ns();
    // 两次 poll 之间只记第一次唤醒
    let _ = self.woke_at.compare_exchange(0, now, Relaxed, Relaxed);
    if let Some(w) = self.real.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
      w.wake_by_ref();
    }
  }
}

标准库的 std::task::Wake 让一个 Arc<T> 直接变成 Waker,省掉手写 RawWakerVTable。tokio-metrics 用的是 futures 的 ArcWake,作用相同(包装 waker)。

compare_exchange(0, now) 是整个库最要紧的一行:任务被唤醒后,tokio 已经把它放进了队列,之后再唤醒几次,对调度没有任何影响;排队应该从第一次唤醒算起。如果写成 store(now),记下的是最后一次唤醒,排队就被低估了。

真正的 waker 放在 Mutex<Option<Waker>> 里。tokio-metrics 用的是 futures 的 AtomicWaker,无锁;这里换成 Mutex 只是为了少一个依赖,代价是 wake 和 poll 都要拿一次锁。

Instrumented:每次 poll 记三样

#[derive(Clone)]
pub struct Monitor {
  shared: Arc<Shared>,
}

impl Monitor {
  pub fn new(slow: Duration) -> Self {
    let shared = Shared { counters: Counters::default(), epoch: Instant::now(), slow };
    Monitor { shared: Arc::new(shared) }
  }

  pub fn instrument<F: Future>(&self, fut: F) -> Instrumented<F> {
    let state = Arc::new(WakeState {
      woke_at: AtomicU64::new(0),
      real: Mutex::new(None),
      shared: self.shared.clone(),
    });
    let waker = Waker::from(state.clone());
    Instrumented { fut: Box::pin(fut), state, waker }
  }
  // snapshot、intervals 见下一节
}

pub struct Instrumented<F> {
  fut: Pin<Box<F>>,
  state: Arc<WakeState>,
  waker: Waker,
}

impl<F: Future> Future for Instrumented<F> {
  type Output = F::Output;

  fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<F::Output> {
    let this = self.get_mut();
    let shared = &this.state.shared;
    let c = &shared.counters;
    let start = Instant::now();
    let start_ns = shared.now_ns();

    // 1. 排队:被唤醒到这次 poll 开始
    let woke_at = this.state.woke_at.swap(0, Relaxed);
    if woke_at > 0 {
      c.scheduled.fetch_add(1, Relaxed);
      c.scheduled_ns.fetch_add(start_ns.saturating_sub(woke_at), Relaxed);
    }

    // 2. 存下真正的 waker,用自己的 waker 去 poll 内层
    {
      let mut real = this.state.real.lock().unwrap_or_else(|e| e.into_inner());
      if !real.as_ref().is_some_and(|w| w.will_wake(cx.waker())) {
        *real = Some(cx.waker().clone());
      }
    }
    let mut inner_cx = Context::from_waker(&this.waker);
    let res = this.fut.as_mut().poll(&mut inner_cx);

    // 3. poll 时长,按阈值分快慢
    let d = start.elapsed();
    c.polls.fetch_add(1, Relaxed);
    c.poll_ns.fetch_add(d.as_nanos() as u64, Relaxed);
    if d >= shared.slow {
      c.slow_polls.fetch_add(1, Relaxed);
    }
    res
  }
}

和 instrument_poll 对照,三处有意的简化:

  • 用 Box::pin 而不是 pin_project!。Instrumented 自己因此是 Unpin,self.get_mut() 就能拿到字段,代价是每个任务多一次堆分配。tokio-metrics 用 pin-project-lite 做结构化投影,没有这次分配。
  • 自己的 waker 在 instrument 时就造好,存在 Instrumented 里,每次 poll 只借用。tokio-metrics 每次 poll 用 waker_ref 现借一个,效果相同(Instrumented 与 instrument_poll「第三段:换 waker」)。
  • 只在 waker 变了时才替换。will_wake 判断调用方这次给的 waker 是不是同一个;任务在 worker 之间迁移、或者被 select! 之类的组合子换了 waker 时才会变。

poll 时长从 start 量起,包括了前面记账和换 waker 的时间;tokio-metrics 量的是内层 poll 前后两次取时间之间,不含包装本身。这个差别在纳秒级。

读的一方:记住上一次,做差

impl Monitor {
  pub fn snapshot(&self) -> Snapshot {
    let c = &self.shared.counters;
    Snapshot {
      polls: c.polls.load(Relaxed),
      poll_ns: c.poll_ns.load(Relaxed),
      slow_polls: c.slow_polls.load(Relaxed),
      scheduled: c.scheduled.load(Relaxed),
      scheduled_ns: c.scheduled_ns.load(Relaxed),
    }
  }

  pub fn intervals(&self) -> Intervals {
    Intervals { monitor: self.clone(), prev: Snapshot::default() }
  }
}

#[derive(Debug, Default, Clone, Copy)]
pub struct Snapshot {
  pub polls: u64,
  pub poll_ns: u64,
  pub slow_polls: u64,
  pub scheduled: u64,
  pub scheduled_ns: u64,
}

impl Snapshot {
  pub fn mean_poll(&self) -> Duration {
    Duration::from_nanos(self.poll_ns.checked_div(self.polls).unwrap_or(0))
  }
  pub fn mean_scheduled(&self) -> Duration {
    Duration::from_nanos(self.scheduled_ns.checked_div(self.scheduled).unwrap_or(0))
  }
  fn delta(&self, prev: &Snapshot) -> Snapshot {
    Snapshot {
      polls: self.polls.wrapping_sub(prev.polls),
      poll_ns: self.poll_ns.wrapping_sub(prev.poll_ns),
      slow_polls: self.slow_polls.wrapping_sub(prev.slow_polls),
      scheduled: self.scheduled.wrapping_sub(prev.scheduled),
      scheduled_ns: self.scheduled_ns.wrapping_sub(prev.scheduled_ns),
    }
  }
}

/// 读的一方:记住上一次,返回差。
pub struct Intervals {
  monitor: Monitor,
  prev: Snapshot,
}

impl Iterator for Intervals {
  type Item = Snapshot;
  fn next(&mut self) -> Option<Snapshot> {
    let now = self.monitor.snapshot();
    let d = now.delta(&self.prev);
    self.prev = now;
    Some(d)
  }
}

这就是 intervals:迭代器差分 的全部思路:prev 从全 0 开始,所以第一次 next() 返回的是累计值;之后每次是和上一次的差;next() 永远返回 Some,多久采一次由调用方决定。几个迭代器各记各的 prev,互不干扰。这里没有区间最大值,所以也没有 swap(0) 那个例外。均值在分母为 0 时返回 0,和 tokio-metrics 的 mean_* 一致。

跑一下

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::time::{Duration, Instant};

use mini::Monitor;

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

/// 手写的让出:唤醒自己、返回一次 Pending
struct WakeSelf(bool);
impl Future for WakeSelf {
  type Output = ();
  fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
    if self.0 {
      return Poll::Ready(());
    }
    self.0 = true;
    cx.waker().wake_by_ref();
    Poll::Pending
  }
}

async fn victim(yield_now: bool) {
  for _ in 0..10 {
    if yield_now {
      tokio::task::yield_now().await;
    } else {
      WakeSelf(false).await;
    }
  }
}

async fn hog() {
  for _ in 0..10 {
    spin(Duration::from_millis(5));
    WakeSelf(false).await;
  }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let slow = Duration::from_micros(50);

  // A:三次「忙 5ms,再睡 1ms」
  let m = Monitor::new(slow);
  let mut iv = m.intervals();
  m.instrument(async {
    for _ in 0..3 {
      spin(Duration::from_millis(5));
      tokio::time::sleep(Duration::from_millis(1)).await;
    }
  })
  .await;
  let a = iv.next().unwrap();
  println!("A mini: polls={} slow={} mean_poll={:?} scheduled={} mean_sched={:?}", a.polls, a.slow_polls, a.mean_poll(), a.scheduled, a.mean_scheduled());

  // B:同一个线程上,hog 每次 poll 忙 5ms,victim 让出 10 次
  for yield_now in [false, true] {
    let label = if yield_now { "yield_now" } else { "wake_self" };
    let m = Monitor::new(slow);
    let h = tokio::spawn(hog());
    let v = tokio::spawn(m.instrument(victim(yield_now)));
    let _ = tokio::join!(h, v);
    let s = m.snapshot();
    println!("B mini   {label}: polls={} scheduled={} mean_sched={:?}", s.polls, s.scheduled, s.mean_scheduled());

    let tm = tokio_metrics::TaskMonitor::new();
    let h = tokio::spawn(hog());
    let v = tokio::spawn(tm.instrument(victim(yield_now)));
    let _ = tokio::join!(h, v);
    let t = tm.cumulative();
    println!(
      "B tokio-metrics {label}: polls={} scheduled={} mean_sched={:?} idled={} mean_idle={:?}",
      t.total_poll_count, t.total_scheduled_count, t.mean_scheduled_duration(), t.total_idled_count, t.mean_idle_duration()
    );
  }
}

M2 Max 笔记本、rustc 1.99.0、--release,其中一次:

A mini: polls=4 slow=3 mean_poll=3.772739ms scheduled=3 mean_sched=333ns
B mini   wake_self: polls=11 scheduled=10 mean_sched=4.501349ms
B tokio-metrics wake_self: polls=11 scheduled=10 mean_sched=4.501766ms idled=0 mean_idle=0ns
B mini   yield_now: polls=11 scheduled=10 mean_sched=433ns
B tokio-metrics yield_now: polls=11 scheduled=10 mean_sched=624ns idled=10 mean_idle=4.517054ms

连跑 3 次,所有计数都相同;mean_poll 在 3.752–3.773ms,wake_self 两边的排队均值在 4.500–4.502ms,两边相差不到 1µs;yield_now 两边的排队均值都在 380–630ns。

  • A:慢 poll 被记到。4 次 poll:前 3 次各忙 5ms,超过 50µs 的阈值,记为慢 poll;第 4 次 sleep 到期、循环结束,几微秒。均值 (3 × 5ms) ÷ 4 ≈ 3.75ms。3 次排队都是定时器唤醒到 poll 开始,几百纳秒。
  • B,wake_self:排队被记到,而且和 tokio-metrics 一致。单线程运行时上,victim 每次唤醒自己就排到 hog 后面,要等 hog 那次 5ms 的 poll 做完。10 次里前 9 次各等约 5ms,最后一次 hog 已经结束,几乎不等:9 × 5ms ÷ 10 = 4.5ms。
  • B,yield_now:两边都记不成排队。规划里原本打算用 yield_now 验证排队,实测不行。tokio 的 yield_now 不是当场唤醒,而是把 waker 交给调度器的 defer 列表,等调度器跑完手头的任务、查过一次驱动才调 wake(包装 waker 的「yield_now 被记成空闲」)。等 wake 真的被调用时,那 5ms 已经过去了,于是排队几乎为 0;tokio-metrics 把这段记成了空闲(mean_idle 4.5ms),mini 没有实现空闲,这段就哪里都没记。所以验证排队要用一个当场唤醒自己的 future,WakeSelf 就是这个用处。

和 tokio-metrics 比:少了什么

tokio-metrics 有的mini补上它要做什么
空闲次数、时长、最大空闲没有每次 poll 结束记 idled_at,下次 poll 时若 idled_at < woke_at 记一次空闲;区间最大值要一个读完 swap(0) 的原子变量
首次 poll 延迟没有instrument 时记下时刻,加一个 did_poll_once 标志
长短排队、快慢 poll 各自的时长只有慢 poll 次数每个桶各一对计数和时长
包装数、丢弃数(在途任务)没有instrument 时加一,Drop for Instrumented 里加一
AtomicWaker 无锁存 wakerMutex<Option<Waker>>换成 futures 的 AtomicWaker
pin_project!,不额外分配Box::pin,每个任务一次分配用 pin-project-lite 做投影
Stream 也能包只能包 Future再写一个 impl Stream,poll_next 走同一段记账
SeqCstRelaxed见下面的问题 2
放进 static 的 TaskMonitorCore、FutureMonitor、metrics-rs 导出没有—

前三行是指标本身,空闲那一行最费事:它是唯一需要在 poll 结束时也记一个时刻的指标。后面几行是工程上的完善。完整实现(含空闲、首次 poll 延迟和 Stream)、导出到 Prometheus,以及配套作业,放在付费课程里。

动脑筋

  1. 把 wake_by_ref 里的 compare_exchange(0, now, ..) 换成 store(now, ..),B 的 wake_self 那一行会变吗?什么样的任务会让两者的差别变大?
  2. tokio-metrics 的原子操作全用 SeqCst,mini 全用 Relaxed。mini 会读到错的数字吗?
  3. 同一个 Monitor 包了 1 000 个任务,跑在 12 个 worker 上,Counters 会成为瓶颈吗?

动脑筋答案

  1. 不会变:WakeSelf 每两次 poll 之间只唤醒一次。差别出在两次 poll 之间被唤醒多次的任务,比如同时等一个 channel 和一个定时器、两者先后就绪。store 记的是最后一次唤醒,排队从那时算起,比任务真正在队列里待的时间短(包装 waker 的「只记第一次唤醒」)。
  2. 不会读到「错」的单个数字:每个计数器是独立的原子变量,fetch_add 在任何内存序下都不会丢更新;woke_at 的 compare_exchange 和 swap 作用在同一个变量上,单变量的修改顺序对所有线程一致。Relaxed 放弃的是跨变量的顺序:读者可能看到 polls 已经加了、poll_ns 还没加。但 tokio-metrics 用 SeqCst 也不保证这一点,它的 19 个字段同样是逐个 load 的,不是同一时刻的快照(设计取舍「写方只累加,读方做差」)。
  3. 会。1 000 个任务的每次 poll 都要写同一组缓存行,多个 worker 同时 poll 时这些缓存行在核之间来回传。设计取舍里的实测:12 个线程共用一个 monitor,每次 poll 约 3.3µs,各用各的只要几百纳秒。实际中按路由或按任务种类拆成多个 monitor,就是在拆这组缓存行。

小结

  • 包装 waker 用 std::task::Wake,compare_exchange(0, now) 只记两次 poll 之间的第一次唤醒,再转给真正的 waker。
  • 每次 poll:swap(0) 取出唤醒时刻记排队,存下调用方的 waker、换成自己的,量 poll 时长并按阈值分快慢。
  • 读的一方:Intervals 记住上一次的读数做差,第一次返回累计值,永远返回 Some。
  • 忙等 5ms 的 poll 被记成慢 poll;唤醒自己的任务在单线程上排在 5ms 的 poll 后面,排队均值 4.5ms,和 tokio-metrics 相差不到 1µs。
  • yield_now 走 tokio 的 defer,唤醒发生在等待之后,两边都记不成排队;验证排队要用当场唤醒自己的 future。

💬 关联讨论

发起讨论
加载中...