包装 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_at | total_idled_count、total_idle_duration、两个最大值 |
| 排队 | woke_at > 0:上次 poll 之后被唤醒过 | poll_start - woke_at | total_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 到它之前花的时间;运行时排队只有最外层量得准。