Rust in Everything
基于 tokio 1.54.1

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 住了。

📝NOTE
冷知识:没 poll 过的 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:登记

Sleep::poll_elapsed:

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;
}

顺序是:

  1. 先扣预算。 poll_proceed 在预算为 0 时直接返回 Pending,哪怕定时器已经到期。所以 Sleep 和 channel、IO 一样受协作式调度预算约束。返回 Ready 时才 made_progress,Pending 时预算会被退回。
  2. Instant 换成 tick。 deadline_to_tick 先加 999,999 纳秒再取整毫秒,即向上取整,保证不会早于 deadline 触发。tick 从运行时创建时刻算起。
  3. init 登记。 TimerEntry::init 调驱动的 reregister:拿驱动锁,写入到期 tick,插入时间轮。如果 tick 已经不晚于时间轮的 elapsed,插入返回 Elapsed,就地 fire;如果比驱动承诺的下次醒来时间(next_wake)还早,unpark 驱动让它重新算超时。
  4. 读状态。 TimerEntry::poll_elapsed 进 StateCell::poll,见下一节。
📝NOTE
没有蠢问题

问: 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,再读状态

StateCell::poll:

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 槽。

💡TIP
打个比方:取快递时先留电话

你去快递柜取件。规矩是:先在柜子旁的登记板上写下自己的电话(register_by_ref 存 waker),再看柜子里有没有你的包裹(read_state)。快递员的规矩反过来:先把包裹放进柜子(state 写成 STATE_DEREGISTERED),再看登记板,有电话就打(take_waker)。

两人同时到场时,不管谁快谁慢,总有一方看到另一方:要么你看柜子时包裹已经在了,要么快递员看登记板时你的电话已经写上了。最坏是两件事都发生,你拿到包裹,又接到一个多余的电话,再 poll 一次而已。不会出现「你没看到包裹、他也没看到电话」。

比方到这里为止:登记板只有一行。你换个手机号再来写,会覆盖原来那个(AtomicWaker 只存一个 waker),所以一个 Sleep 同一时刻只能叫醒一个任务。

reset:往后推不用锁

TimerEntry::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 之后的写都在驱动以后的操作之前。

📝NOTE
冷知识: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 毫秒):

dceil(f + d)观察到
1 µs、100 µs1约 1.1 ms
1.5 msf < 0.5 时 2,f ≥ 0.5 时 32.26 或 3.39 ms,两种都出现了
2 msf > 0 时都是 3约 3.4 ms

结论:sleep 永远不会早于 d 返回,但可能晚到将近 1 毫秒再加调度开销;亚毫秒的 sleep 实际都是 1 毫秒多。用 sleep 做高频小步等待,100 次 100 微秒变成了 123 毫秒。

❗IMPORTANT
动脑筋:时钟暂停后,同样的 sleep 要多久?

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,等于永不超时。

Timeout::poll 和 poll_delay:

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。

📝NOTE
冷知识:NaiveTimeout 就是 1.15.0 之前的 timeout

1.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。

poll_tick:

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按原来的网格一个个补,补的那几次会立即连续返回
Delaynow + period从现在起重新计时,网格整体后移
Skipnow + (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 跳到原网格的下一个点。

💬 关联讨论

发起讨论
加载中...