Sleep 的一生
主线位置:任务主线的「遇到
Pending挂起 →Waker唤醒」。这里的叶子 future 是Sleep,叫醒它的是时间驱动。
sequenceDiagram participant T as 任务 participant S as Sleep participant D as 时间驱动 T->>S: sleep(d):只记 deadline T->>S: 第一次 poll S->>D: init:加锁,插入时间轮 S->>S: 注册 waker,读状态 S-->>T: Pending D->>D: park 到期,process D->>S: fire:写结果,状态改为已触发 D->>T: wake T->>S: 再次 poll S-->>T: Ready T->>S: drop:加锁,确认已摘下
创建:不碰时间轮
sleep 和 sleep_until 都落到 Sleep::new_timeout:
pub fn sleep(duration: Duration) -> Sleep {
let deadline = Instant::now() + safe_delay(duration);
Sleep::new_timeout(deadline, trace::caller_location())
}
pub(crate) fn new_timeout(
deadline: Instant,
location: Option<&'static Location<'static>>,
) -> Sleep {
let handle = scheduler::Handle::current();
// Panic if the time driver is not enabled (backwards compat)
_ = handle.driver().time();
// ...
Sleep {
deadline,
driver: handle,
inner,
timer: None,
}
}
它做了两件事:拿到当前运行时的句柄(不在运行时里就 panic),确认时间驱动是开着的(没调 enable_time 也 panic)。时间轮完全没碰,Sleep 里的 timer 是 None:
pub struct Sleep {
deadline: Instant,
driver: scheduler::Handle,
inner: Inner,
#[pin]
timer: Option<Timer>,
}
Timer 是 runtime/mod.rs 里的一个枚举,Traditional(TimerEntry) 是本页讲的全局时间轮,Alternative 是 time_alt 的实现,只在 tokio_unstable 下存在。
为什么要懒:Sleep 常常被创建了却从不 poll,或者只在 select! 里当一个备用分支。不 poll 就不登记,省掉一次加锁和一次插入。更重要的是,TimerShared 是侵入式链表的节点,登记之后地址不能变,而 Sleep 在被 pin 之前是可以移动的。第一次 poll 时它一定已经被 pin 住了。
Sleep,以前 drop 时也要拿锁登记本来就是懒的,但在 1.38.0 之前,TimerEntry 一创建就带着一个初始化好的 TimerShared,drop 时不管登记没登记,都要调 clear_entry 拿一次驱动锁。issue #6504 收集了「大量定时器时驱动锁竞争严重」的报告,列出的第一个想法就是这一点:没被 poll 就 drop 的 Sleep 也会去锁那把互斥锁,而 timeout 常常就是这样被取消的。
2024 年 5 月的 #6512(1.38.0)把 TimerShared 改成第一次 poll 时才初始化,drop 时没初始化过就直接返回。PR 在 M1 上的基准里,8 线程的 multi_thread_timeout 从约 183 ns 降到约 4.6 ns。本页说的「没被 poll 过的 Sleep drop 时什么都不做」就是从这里来的。
第一次 poll:登记
fn poll_elapsed(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Result<(), Error>> {
ready!(crate::trace::trace_leaf(cx));
let mut this = self.project();
// ...
let coop = ready!(crate::task::coop::poll_proceed(cx));
let timer = match this.timer.as_mut().as_pin_mut() {
Some(timer) => timer,
None => {
let handle = this.driver;
let time_source = handle.driver().time().time_source();
let deadline = time_source.deadline_to_tick(*this.deadline);
// ...
let timer = Timer::new(handle.clone(), deadline);
this.timer.set(Some(timer));
let mut timer = this.timer.as_pin_mut().unwrap();
timer.as_mut().init(deadline);
timer
}
};
let result = timer.poll_elapsed(cx).map(move |r| {
coop.made_progress();
r
});
// ...
return result;
}
顺序是:
- 先扣预算。
poll_proceed在预算为 0 时直接返回Pending,哪怕定时器已经到期。所以Sleep和 channel、IO 一样受协作式调度预算约束。返回Ready时才made_progress,Pending时预算会被退回。 Instant换成 tick。deadline_to_tick先加 999,999 纳秒再取整毫秒,即向上取整,保证不会早于 deadline 触发。tick 从运行时创建时刻算起。init登记。TimerEntry::init调驱动的reregister:拿驱动锁,写入到期 tick,插入时间轮。如果 tick 已经不晚于时间轮的elapsed,插入返回Elapsed,就地 fire;如果比驱动承诺的下次醒来时间(next_wake)还早,unpark驱动让它重新算超时。- 读状态。
TimerEntry::poll_elapsed进StateCell::poll,见下一节。
问: sleep(Duration::ZERO).await 会立刻返回吗?
答: 通常不会,要等 1 毫秒左右。源码里没有「时长为 0 就直接返回」的快路径。deadline 是 Instant::now(),deadline_to_tick 加 999,999 纳秒再取整,只要此刻不恰好落在整毫秒上,得到的就是下一个毫秒的 tick。它比时间轮的 elapsed 晚(elapsed 最多推到向下取整的现在),insert 不会返回 Elapsed,定时器被插进时间轮。驱动 park 时,到期 tick 减去向下取整的 now 是 1,于是 park 1 毫秒。
在 M2 Max、rustc 1.99.0、--release 下连续 5 次 sleep(Duration::ZERO),跑了 3 遍,每次都在 1.01 到 1.18 毫秒之间。只是想让出 CPU,用 tokio::task::yield_now():同一个程序里 100 次 yield_now 总共 1.2 到 1.3 毫秒。后面 interval 实验里 #0 落在 1 毫秒,也是同一个原因。
TimerShared:一个 u64 当状态机
TimerEntry 内嵌一个 TimerShared,它就是时间轮链表里的节点(下面省略了文档注释):
pub(crate) struct TimerShared {
pointers: linked_list::Pointers<TimerShared>,
registered_when: AtomicU64,
state: StateCell,
_p: PhantomPinned,
}
pub(super) struct StateCell {
state: AtomicU64,
result: UnsafeCell<TimerResult>,
waker: AtomicWaker,
}
StateCell::state 一个字段表示全部状态,靠的是把 u64 最大的两个值留作标记(entry.rs 第 71 行):
pub(in crate::runtime::time) const STATE_DEREGISTERED: u64 = u64::MAX;
const STATE_PENDING_FIRE: u64 = STATE_DEREGISTERED - 1;
const STATE_MIN_VALUE: u64 = STATE_PENDING_FIRE;
/// The largest safe integer to use for ticks.
pub(super) const MAX_SAFE_MILLIS_DURATION: u64 = STATE_MIN_VALUE - 1;
state 的值 | 含义 |
|---|---|
0 ..= u64::MAX - 2 | 已登记,值就是真实的到期 tick |
u64::MAX - 1(STATE_PENDING_FIRE) | 已到期,在时间轮的 pending 链表里等 fire |
u64::MAX(STATE_DEREGISTERED) | 不在时间轮里:还没登记,或已经 fire / 取消。result 里放着结果 |
registered_when 是另一个字段:定时器插入时用的 tick,用来在删除时找到它所在的层和槽。两个 tick 可以不同,原因在 reset(下一节)。
stateDiagram-v2 state "已登记 tick" as REG [*] --> DEREGISTERED: 新建 DEREGISTERED --> REG: reregister,持锁 REG --> REG: reset 推后,CAS 无锁 REG --> PENDING_FIRE: mark_pending,持锁 PENDING_FIRE --> DEREGISTERED: fire,持锁 REG --> DEREGISTERED: cancel,持锁
访问规则写在 entry.rs 的模块注释里:TimerShared 只能通过两种途径访问,持有 &mut TimerEntry,或者持有驱动锁。把定时器交给驱动(登记)两者都要;从驱动收回(fire)只要锁。所以大部分写操作可以用 Relaxed,靠锁的 acquire/release 保证可见性。
poll:先注册 waker,再读状态
fn poll(&self, waker: &Waker) -> Poll<TimerResult> {
// We must register first. This ensures that either `fire` will
// observe the new waker, or we will observe a racing fire to have set
// the state, or both.
self.waker.register_by_ref(waker);
self.read_state()
}
fn read_state(&self) -> Poll<TimerResult> {
let cur_state = self.state.load(Ordering::Acquire);
if cur_state == STATE_DEREGISTERED {
// SAFETY: The driver has fired this timer; this involves writing
// the result, and then writing (with release ordering) the state
// field.
Poll::Ready(unsafe { self.result.with(|p| *p) })
} else {
Poll::Pending
}
}
poll 时不拿驱动锁。和 fire 赛跑的处理方式是固定顺序:poll 先存 waker 再读状态,fire 先写状态再取 waker(下面的 fire)。不管怎么交错,要么 poll 读到「已触发」,要么 fire 拿到新 waker,不会两头落空。AtomicWaker 是 sync/task/atomic_waker.rs 里那个无锁的单 waker 槽。
你去快递柜取件。规矩是:先在柜子旁的登记板上写下自己的电话(register_by_ref 存 waker),再看柜子里有没有你的包裹(read_state)。快递员的规矩反过来:先把包裹放进柜子(state 写成 STATE_DEREGISTERED),再看登记板,有电话就打(take_waker)。
比方到这里为止:登记板只有一行。你换个手机号再来写,会覆盖原来那个(AtomicWaker 只存一个 waker),所以一个 Sleep 同一时刻只能叫醒一个任务。
reset:往后推不用锁
pub(crate) fn reset(self: Pin<&mut Self>, deadline: u64) {
if self.inner.extend_expiration(deadline).is_ok() {
return;
}
unsafe {
self.driver()
.reregister(&self.driver.driver().io, deadline, (&self.inner).into());
}
}
extend_expiration 只在「已登记,且新 tick 不早于旧 tick」时成功,做法是一次 CAS 把 state 改成新 tick。定时器还留在旧的槽里,registered_when 也还是旧值。等时间走到旧槽,process_expiration 里的 mark_pending 读到更晚的真实 tick,CAS 失败,把它下放到新位置(见分层时间轮)。
这是给「不断续期的超时」准备的:比如每收到一个包就把空闲超时推后 30 秒,这种 reset 绝大部分时候一个 CAS 就完成,不碰全局锁。往前提、已经到期、或者在 pending 里,才走 reregister 加锁重插。
mark_pending 也是 CAS,所以「驱动正要判定到期」和「用户刚刚推后」赛跑时,最多是驱动的 CAS 失败、把定时器下放,不会提前触发。
fire:驱动这一侧
StateCell::fire,必须持有驱动锁:
unsafe fn fire(&self, result: TimerResult) -> Option<Waker> {
// Quick initial check to see if the timer is already fired. ...
let cur_state = self.state.load(Ordering::Relaxed);
if cur_state == STATE_DEREGISTERED {
return None;
}
// ...
unsafe { self.result.with_mut(|p| *p = result) };
self.state.store(STATE_DEREGISTERED, Ordering::Release);
self.waker.take_waker()
}
先写结果,再用 Release 写状态;read_state 用 Acquire 读到 STATE_DEREGISTERED 时,结果一定可见。waker 被取出来交给调用方,在锁外调用(process_at_time 每攒 32 个放一次锁)。任务被唤醒、重新调度,再 poll 时 read_state 返回 Ready(Ok(())),Sleep::poll 返回 Ready(())。
结果只有两种:Ok(()),或者运行时关闭时 reregister 写入的 shutdown 错误。Sleep 的 Future 实现遇到错误直接 panic,注释的说法是这属于「逻辑错误」,用户也做不了什么。
drop:总要拿一次锁
TimerEntry 的 PinnedDrop 调 cancel,再到驱动的 clear_entry:
pub(self) unsafe fn clear_entry(&self, entry: NonNull<TimerShared>) {
unsafe {
let mut lock = self.inner.lock();
if entry.as_ref().might_be_registered() {
lock.wheel.remove(entry);
}
let waker = entry.as_ref().handle().fire(Ok(()));
drop(lock);
drop(waker);
}
}
即使定时器早已 fire、不在任何链表里,也要拿锁。cancel 上方的长注释给了原因:这块内存马上要被释放、挪作他用,而驱动线程之前对它做过一些 Relaxed 甚至非原子的写。拿一次锁,相当于在 drop 线程和驱动线程之间建立双向的 happens-before:驱动之前的写都在 drop 之前完成,drop 之后的写都在驱动以后的操作之前。
drop(lock) 写在 drop(waker) 前面,是修一个死锁clear_entry 里的 fire 会把之前登记的 waker 取出来。1.51.5 之前,这个返回值是个临时值,在锁的作用域结束前就被析构了,也就是持着驱动锁运行 waker 的 Drop。#8552 的说明指出:如果 waker 的 Drop 又去 drop 或 reset 同一个运行时上另一个已登记的 Sleep,就会再拿一次驱动锁,死锁。修复就是代码里那两行:先 drop(lock),再 drop(waker)。这和 process_at_time 放锁后才 wake_all 是同一个道理,waker 是用户代码,不能在锁里碰。
所以在传统实现里,每个被 poll 过的 Sleep,一生至少拿两次全局驱动锁:登记一次,drop 一次。没被 poll 过的 Sleep(timer 是 None)drop 时什么都不做。多线程、大量短定时器的场景下,这把锁就是 time_alt 想去掉的东西。
实验:毫秒取整
时间轮精度 1 毫秒,deadline 向上取整,park 时长用「到期 tick 减去向下取整的 now」。两次取整叠在一起,实际睡眠时间是多少:
#[tokio::main(flavor = "current_thread")]
async fn main() {
for d in [Duration::from_micros(1), Duration::from_micros(100), Duration::from_micros(1500), Duration::from_millis(2)] {
let mut v = Vec::new();
for _ in 0..5 {
let t = Instant::now();
sleep(d).await;
v.push(format!("{:.3}", t.elapsed().as_secs_f64() * 1000.0));
}
println!("sleep({d:?}) 实际(ms): {}", v.join(" "));
}
// 连续 100 次 sleep(100µs),总共睡了多久
let t = Instant::now();
for _ in 0..100 {
sleep(Duration::from_micros(100)).await;
}
println!("100 x sleep(100µs) 总计 {:.1}ms", t.elapsed().as_secs_f64() * 1000.0);
}
sleep(1µs) 实际(ms): 1.169 1.144 1.087 1.134 1.143
sleep(100µs) 实际(ms): 1.140 1.140 1.143 1.137 1.131
sleep(1.5ms) 实际(ms): 3.386 3.387 2.260 3.387 2.273
sleep(2ms) 实际(ms): 3.225 3.403 3.393 3.396 3.389
100 x sleep(100µs) 总计 123.0ms
设调用 sleep(d) 时距运行时启动 n + f 毫秒(n 整数,0 < f < 1)。到期 tick 是 ceil(n + f + d),park 时长是这个 tick 减 n,所以实际睡眠约为 ceil(f + d) 毫秒,再加操作系统唤醒的开销(这台 macOS 上 0.1–0.4 毫秒):
d | ceil(f + d) | 观察到 |
|---|---|---|
| 1 µs、100 µs | 1 | 约 1.1 ms |
| 1.5 ms | f < 0.5 时 2,f ≥ 0.5 时 3 | 2.26 或 3.39 ms,两种都出现了 |
| 2 ms | f > 0 时都是 3 | 约 3.4 ms |
结论:sleep 永远不会早于 d 返回,但可能晚到将近 1 毫秒再加调度开销;亚毫秒的 sleep 实际都是 1 毫秒多。用 sleep 做高频小步等待,100 次 100 微秒变成了 123 毫秒。
M2 Max、rustc 1.99.0。把上面的实验搬到暂停的时钟下(本页最后一节),用 tokio::time::Instant 量 tokio 时钟走了多少:
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
for d in [Duration::from_micros(100), Duration::from_micros(1500), Duration::from_millis(2)] {
let t = Instant::now();
for _ in 0..10 {
sleep(d).await;
}
println!("10 x sleep({d:?}): tokio 时钟走了 {:?}", t.elapsed());
}
}
三行各是多少毫秒?真实时钟下,sleep(2ms) 每次约 3.4 毫秒,这里还是吗?
提示:套用上面的 ceil(f + d),想想暂停时钟下 f 是多少。答案在本页末尾。
timeout:先 poll 内层,再看定时器
timeout 把 future 和一个 Sleep 装在一起:
pub fn timeout<F>(duration: Duration, future: F) -> Timeout<F::IntoFuture>
where
F: IntoFuture,
{
// Closures don't preserve `#[track_caller]`.
#[allow(clippy::manual_map)]
Timeout {
value: future.into_future(),
delay: match Instant::now().checked_add(duration) {
Some(deadline) => Some(Sleep::new_timeout(deadline, trace::caller_location())),
None => None,
},
}
}
Instant 加法溢出(比如传了 Duration::MAX)时 delay 是 None,等于永不超时。
fn poll(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Self::Output> {
let me = self.project();
let had_budget_before = coop::has_budget_remaining();
// First, try polling the future
if let Poll::Ready(v) = me.value.poll(cx) {
return Poll::Ready(Ok(v));
}
match me.delay.as_pin_mut() {
Some(delay) => poll_delay(had_budget_before, delay, cx).map(Err),
None => Poll::Pending,
}
}
fn poll_delay(
had_budget_before: bool,
delay: Pin<&mut Sleep>,
cx: &mut task::Context<'_>,
) -> Poll<Elapsed> {
let delay_poll = || match delay.poll(cx) {
Poll::Ready(()) => Poll::Ready(Elapsed::new()),
Poll::Pending => Poll::Pending,
};
let has_budget_now = coop::has_budget_remaining();
if let (true, false) = (had_budget_before, has_budget_now) {
// if it is the underlying future that exhausted the budget, we poll
// the `delay` with an unconstrained one. ...
coop::with_unconstrained(delay_poll)
} else {
delay_poll()
}
}
- 内层优先。 内层和定时器同时就绪时,返回内层的结果,不算超时。
- 预算。
Sleep会检查预算。如果内层 future 每次都把 128 的预算用完才返回Pending,紧接着 poll 的Sleep永远拿到 0 预算、永远返回Pending,超时就永远不会触发。所以Timeout记下进来时有没有预算:进来时有、内层 poll 完没了,说明是内层用光的,这时用with_unconstrained不受限地 poll 一次定时器。进来时就没有预算,则照常 poll,Sleep返回Pending,交给调度器。
实验:内层一直就绪
内层 future 自己发一条、自己收一条,recv 永远就绪,每次都扣预算,500 毫秒后自己结束。对照组 NaiveTimeout 也是先 poll 内层、再 poll sleep,只是少了 with_unconstrained 那一步:
struct NaiveTimeout<F> {
inner: Pin<Box<F>>,
delay: Pin<Box<Sleep>>,
}
impl<F: Future> Future for NaiveTimeout<F> {
type Output = Result<F::Output, &'static str>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if let Poll::Ready(v) = self.inner.as_mut().poll(cx) {
return Poll::Ready(Ok(v));
}
self.delay.as_mut().poll(cx).map(|_| Err("Elapsed"))
}
}
async fn busy_reader() -> u64 {
let (tx, mut rx) = mpsc::unbounded_channel::<u64>();
let start = Instant::now();
let mut n = 0u64;
loop {
tx.send(n).unwrap();
rx.recv().await;
n += 1;
if start.elapsed() > Duration::from_millis(500) {
return n;
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let start = Instant::now();
let res = timeout(Duration::from_millis(50), busy_reader()).await;
println!("timeout: {:?},用时 {}ms", res.map_err(|_| "Elapsed"), start.elapsed().as_millis());
let start = Instant::now();
let res = NaiveTimeout { inner: Box::pin(busy_reader()), delay: Box::pin(sleep(Duration::from_millis(50))) }.await;
println!("NaiveTimeout: {:?},用时 {}ms", res.map(|_| "内层自己结束"), start.elapsed().as_millis());
}
timeout: Err("Elapsed"),用时 50ms
NaiveTimeout: Ok("内层自己结束"),用时 500ms
NaiveTimeout 的 50 毫秒超时从未生效:每次 poll,内层都把预算用到 0,sleep 在 poll_proceed 那一步就返回了 Pending。
NaiveTimeout 就是 1.15.0 之前的 timeout1.15.0 之前,Timeout::poll 正是上面 NaiveTimeout 的写法:先 poll 内层,再直接 poll delay,没有 had_budget_before 这一步。2021 年 12 月的 #4314 的说明写道:如果被套上超时的 future 每次都把预算用光,超时就永远得不到检查的机会,下一次 poll 内层又会把预算用光,超时形同虚设,这会让人意外。修复是给 timeout 开一个例外:预算是内层用光的,就不受限地 poll 一次 delay,随 1.15.0 发布。
interval:每次 tick 后懒重置
interval 经 internal_interval_at 建出来,内部就是一个 Pin<Box<Sleep>>,第一个 deadline 是 Instant::now()(interval_at 则是给定的 start),默认 MissedTickBehavior::Burst。
pub fn poll_tick(&mut self, cx: &mut Context<'_>) -> Poll<Instant> {
// Wait for the delay to be done
ready!(Pin::new(&mut self.delay).poll(cx));
// Get the time when we were scheduled to tick
let timeout = self.delay.deadline();
let now = Instant::now();
// ...
let next = if now > timeout + Duration::from_millis(5) {
self.missed_tick_behavior
.next_timeout(timeout, now, self.period)
} else {
timeout + self.period
};
// When we arrive here, the internal delay returned `Poll::Ready`.
// Reset the delay but do not register it. It should be registered with
// the next call to [`poll_tick`].
self.delay.as_mut().reset_without_timer(next);
// Return the time when we were scheduled to tick
Poll::Ready(timeout)
}
- 返回的是计划的 tick 时刻,不是现在。
- 晚了不超过 5 毫秒都算准时,下一次就是
timeout + period。 reset_without_timer只改deadline,把timer设回None:旧的TimerEntry被 drop(拿一次锁),下一次poll_tick再按「第一次 poll」的路径登记。tick 之后如果很久才再调tick(),这段时间里时间轮里没有它。
晚了超过 5 毫秒,交给 MissedTickBehavior::next_timeout:
fn next_timeout(&self, timeout: Instant, now: Instant, period: Duration) -> Instant {
match self {
Self::Burst => timeout + period,
Self::Delay => now + period,
Self::Skip => {
let offset = Duration::from_nanos(
((now - timeout).as_nanos() % period.as_nanos())
.try_into()
// ...
.expect(
"too much time has elapsed since the interval was supposed to tick",
),
);
now + (period - offset)
}
}
}
| 模式 | 下一次 | 效果 |
|---|---|---|
Burst(默认) | timeout + period | 按原来的网格一个个补,补的那几次会立即连续返回 |
Delay | now + period | 从现在起重新计时,网格整体后移 |
Skip | now + (period - (now - timeout) % period) | 跳过错过的,落到原网格上 now 之后的下一个点 |
实验:卡住 350 毫秒
周期 100 毫秒,第 2 次 tick(#1)之后用 std::thread::sleep 卡住线程 350 毫秒,三种模式各跑一遍:
#[tokio::main(flavor = "current_thread")]
async fn main() {
for behavior in [MissedTickBehavior::Burst, MissedTickBehavior::Delay, MissedTickBehavior::Skip] {
let start = Instant::now();
let mut iv = interval(Duration::from_millis(100));
iv.set_missed_tick_behavior(behavior);
let mut line = format!("{behavior:?}:");
for i in 0..6 {
let scheduled = iv.tick().await;
let actual = start.elapsed().as_millis();
line += &format!(" #{i} 计划{}/实际{}", (scheduled - start).as_millis(), actual);
if i == 1 {
// 第 2 次 tick 之后卡住 350ms,期间错过 3 个 tick
std::thread::sleep(Duration::from_millis(350));
}
}
println!("{line}");
}
}
Burst: #0 计划0/实际1 #1 计划100/实际102 #2 计划200/实际453 #3 计划300/实际453 #4 计划400/实际453 #5 计划500/实际502
Delay: #0 计划0/实际1 #1 计划100/实际101 #2 计划200/实际455 #3 计划555/实际557 #4 计划655/实际657 #5 计划755/实际757
Skip: #0 计划0/实际1 #1 计划100/实际101 #2 计划200/实际456 #3 计划500/实际502 #4 计划600/实际602 #5 计划700/实际702
三种模式下 #2 都在 450 多毫秒才返回,计划时刻都是 200。之后:
- Burst:
#3、#4计划是 300、400,和#2在同一毫秒连续返回,#5回到网格上的 500。 - Delay:
#2在 455 返回,下一次是 455 + 100 = 555,之后 655、755,网格后移了 55 毫秒。 - Skip:
#2在 456 返回,(456 - 200) % 100 = 56,下一次是 456 + 44 = 500。300 和 400 被跳过,之后仍在原网格上。
还有一个细节:#0 的实际时刻是 1 毫秒,不是 0。第一次 tick 的 deadline 是 Instant::now(),向上取整后落在下一个毫秒,比时间轮的 elapsed 晚,于是被插入时间轮,要等驱动 park 1 毫秒后才触发。
暂停的时钟
开了 test-util feature 后,#[tokio::main(start_paused = true)] 或 time::pause() 让时钟停住。驱动 park 时(park_thread_timeout)发现可以自动推进,就不真睡,而是把时钟直接拨到下一个定时器(见分层时间轮最后一节):
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let wall = std::time::Instant::now();
let t = Instant::now();
sleep(Duration::from_secs(3600)).await;
println!("tokio 时钟走了 {:?},墙上时间走了 {:?}", t.elapsed(), wall.elapsed());
}
tokio 时钟走了 3600s,墙上时间走了 65.375µs
所以用 tokio::time::Instant 而不是 std::time::Instant 计时,测试里的定时逻辑才能被暂停的时钟控制。暂停时钟要求 current_thread 运行时,否则 pause 返回错误。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出完全相同:
10 x sleep(100µs): tokio 时钟走了 10ms
10 x sleep(1.5ms): tokio 时钟走了 20ms
10 x sleep(2ms): tokio 时钟走了 20ms
关键是 f。暂停的时钟从运行时创建那一刻停住,TimeSource 的起点就是这一刻,所以开始时 f = 0。驱动 park 时不真睡,而是把时钟拨快 tick_to_duration(when - now),这是整毫秒数,拨完之后 f 还是 0。每次 sleep 都是 ceil(0 + d) 毫秒,没有操作系统唤醒开销:
- 100 µs:
ceil(0.1) = 1,每次 1 毫秒,10 次 10 毫秒。 - 1.5 ms:
ceil(1.5) = 2,每次 2 毫秒,共 20 毫秒。真实时钟下f随机,2 和 3 都会出现。 - 2 ms:
ceil(2) = 2,正好 2 毫秒,共 20 毫秒。真实时钟下f > 0,每次变成 3 毫秒多。
所以暂停时钟下的定时测试量出来的是「理想」时长:取整只在 d 不是整毫秒时多算,不会像真实时钟那样多出将近 1 毫秒。拿它测出来的数估计线上耗时,会偏小。
小结
Sleep创建时只记 deadline;第一次 poll 才把Instant向上取整成毫秒 tick、加锁插入时间轮。TimerShared内嵌在Sleep里,是时间轮链表的节点;state一个u64表示到期 tick、待触发、已触发三种状态。- poll 不拿锁:先注册 waker 再读状态,fire 先写状态再取 waker,赛跑时总有一方看到另一方。
reset往后推只要一次 CAS,时间轮在旧槽到期时才发现并下放;往前提才加锁重插。- 被 poll 过的
Sleep在 drop 时一定拿一次驱动锁,用来和驱动线程同步内存。 - 精度 1 毫秒:
sleep(d)实际约ceil(f + d)毫秒,亚毫秒 sleep 都是 1 毫秒多。 timeout先 poll 内层;预算被内层用光时,不受限地 poll 一次定时器,保证超时能触发。interval晚 5 毫秒以内算准时;更晚时 Burst 补齐、Delay 后移、Skip 跳到原网格的下一个点。