从零写一个 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_idle4.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 无锁存 waker | Mutex<Option<Waker>> | 换成 futures 的 AtomicWaker |
pin_project!,不额外分配 | Box::pin,每个任务一次分配 | 用 pin-project-lite 做投影 |
Stream 也能包 | 只能包 Future | 再写一个 impl Stream,poll_next 走同一段记账 |
SeqCst | Relaxed | 见下面的问题 2 |
放进 static 的 TaskMonitorCore、FutureMonitor、metrics-rs 导出 | 没有 | — |
前三行是指标本身,空闲那一行最费事:它是唯一需要在 poll 结束时也记一个时刻的指标。后面几行是工程上的完善。完整实现(含空闲、首次 poll 延迟和 Stream)、导出到 Prometheus,以及配套作业,放在付费课程里。
动脑筋
- 把
wake_by_ref里的compare_exchange(0, now, ..)换成store(now, ..),B 的wake_self那一行会变吗?什么样的任务会让两者的差别变大? - tokio-metrics 的原子操作全用
SeqCst,mini 全用Relaxed。mini 会读到错的数字吗? - 同一个
Monitor包了 1 000 个任务,跑在 12 个 worker 上,Counters会成为瓶颈吗?
动脑筋答案
- 不会变:
WakeSelf每两次 poll 之间只唤醒一次。差别出在两次 poll 之间被唤醒多次的任务,比如同时等一个 channel 和一个定时器、两者先后就绪。store记的是最后一次唤醒,排队从那时算起,比任务真正在队列里待的时间短(包装 waker 的「只记第一次唤醒」)。 - 不会读到「错」的单个数字:每个计数器是独立的原子变量,
fetch_add在任何内存序下都不会丢更新;woke_at的compare_exchange和swap作用在同一个变量上,单变量的修改顺序对所有线程一致。Relaxed放弃的是跨变量的顺序:读者可能看到polls已经加了、poll_ns还没加。但 tokio-metrics 用SeqCst也不保证这一点,它的 19 个字段同样是逐个load的,不是同一时刻的快照(设计取舍「写方只累加,读方做差」)。 - 会。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。