Rust in Everything
基于 tokio 1.54.1

分层时间轮

主线位置:任务主线的「Pending 挂起 → Waker 唤醒」之间。任务在 Sleep 上挂起后,叫醒它的是这个时间轮;worker park 多久,也由它决定。

flowchart TB
  ins[insert:算 level_for] --> lv[放进某层某槽,置 occupied 位]
  park[park_internal] --> ne[next_expiration:各层找最近的非空槽]
  ne --> to[算出 park 超时]
  to --> wake[醒来:process_at_time]
  wake --> take[取出到期槽的整条链表]
  take -->|when 已到| pend[进 pending 链表]
  take -->|when 未到| down[按新的 elapsed 重算层,放回更低一层]
  pend --> fire[fire,攒满 32 个 waker 就放锁唤醒]
💡TIP
打个比方:秘书的跟进文件夹

秘书桌上有一排「日」文件夹,1 号到 31 号;旁边一排「月」文件夹,1 月到 12 月。三天后要办的事,放进那天的日夹子;半年后的事,先放进那个月的月夹子,具体哪天暂时不管(level_for 选层,slot_for 选槽)。每个夹子上插一面小旗,里面有东西就竖起来。秘书扫一眼小旗就知道下一件事在哪个夹子,不用一个个翻(occupied 位图)。

到了某个月的 1 号,秘书把这个月的月夹子整个倒出来,按日期分进日夹子(下放)。这一天他没办任何正事,只是在挪纸。

比方到这里为止:日历按「几号」分夹子,tokio 按二进制位分。离得近不一定进低层:从 60 毫秒到 70 毫秒只差 10 毫秒,但跨过了 64 这条边界,就要先进第 1 层,见下面的 level_for。另外 tokio 有 6 排夹子,每排都是 64 个。

结构:6 层,每层 64 个槽

Wheel 只有三个字段:

pub(crate) struct Wheel {
    /// The number of milliseconds elapsed since the wheel started.
    elapsed: u64,

    /// Timer wheel.
    /// ...
    levels: Box<[Level; NUM_LEVELS]>,

    /// Entries queued for firing
    pending: LinkedList<TimerShared>,
}

const NUM_LEVELS: usize = 6;

const BITS_PER_LEVEL: usize = 6;

/// The maximum duration of a `Sleep`.
const MAX_DURATION: u64 = 1 << (BITS_PER_LEVEL * NUM_LEVELS);

时间轮里的时间都是毫秒数(tick),从运行时创建那一刻算起。elapsed 是时间轮已经处理到的时刻,不一定等于现在:它只在驱动醒来处理时往前走。

每一层是一个 Level:64 条侵入式链表,加一个 u64 位图 occupied,第 i 位表示第 i 个槽非空。链表节点就是 Sleep 里的 TimerShared,插入不分配内存(见 Sleep 的一生)。

第 k 层每个槽覆盖 64^k 毫秒,整层覆盖 64^(k+1) 毫秒,即上一层的一个槽正好等于下一层的一整圈:

层每槽宽度整层范围约合
01 ms64 ms64 毫秒
164 ms4,096 ms4.1 秒
24,096 ms262,144 ms4.4 分钟
3262,144 ms16,777,216 ms4.7 小时
416,777,216 ms1,073,741,824 ms12.4 天
51,073,741,824 ms68,719,476,736 ms795 天,约 2.18 年

最后一行就是 MAX_DURATION = 2^36 毫秒。这里的「范围」是相对 elapsed 对齐后的窗口,不是「离现在多远」,下一节会看到区别。

为什么是 64:一层的状态正好装进一个 u64 位图,找下一个非空槽是一条 trailing_zeros 指令;槽号就是 tick 的某 6 位,取槽号只要移位加取模,见 slot_for:

fn slot_for(duration: u64, level: usize) -> usize {
    ((duration >> (level * super::BITS_PER_LEVEL)) % LEVEL_MULT as u64) as usize
}

第 0 层的槽号是 tick 的第 0–5 位,第 1 层是第 6–11 位,以此类推。

选层:elapsed ^ when 的最高位

插入时先定层,再定槽。定层的函数 level_for 只有几行:

fn level_for(elapsed: u64, when: u64) -> usize {
    const SLOT_MASK: u64 = (1 << BITS_PER_LEVEL) - 1;

    // Mask in the trailing bits ignored by the level calculation in order to cap
    // the possible leading zeros
    let masked = elapsed ^ when | SLOT_MASK;

    if masked >= MAX_DURATION {
        // Fudge the timer into the top level
        return NUM_LEVELS - 1;
    }

    masked.ilog2() as usize / BITS_PER_LEVEL
}

思路:把 tick 看成 6 位一组的「数字」,第 k 组就是第 k 层的槽号。elapsed 和 when 从高往低比,第一个不同的组决定层号。比这一组更高的组都相同,说明 when 落在 elapsed 所在的那一圈里,这一层的槽能直接表示它;更低的组暂时不用管,等时间走到这个槽再细分。

elapsed ^ when 的最高位就是第一个不同的二进制位,ilog2 取它的位置,除以 6 得到组号。| SLOT_MASK 把低 6 位补成 1:一是保证 ilog2 的参数不为 0,二是差异只在低 6 位时结果固定为第 0 层。

手算一个:elapsed = 0,when = 100。

步骤值二进制
elapsed ^ when100000001 100100
| 63127000001 111111
ilog26最高位是第 6 位
6 / 61第 1 层
slot_for(100, 1)100 >> 6 = 1第 1 层第 1 槽

把 level_for 和 slot_for 原样抄进一个程序,多算几组:

fn main() {
  for (elapsed, when) in [(0u64, 10u64), (0, 100), (60, 70), (0, 4200), (64, 100), (4096, 4200), (4160, 4200), (0, 1 << 40), (1 << 36, 1 << 40)] {
    let x = elapsed ^ when;
    let level = level_for(elapsed, when);
    println!(
      "elapsed={elapsed:>6} when={when:>14} xor={x:>14} masked.ilog2={:>2} level={level} slot={}",
      (x | 63).ilog2(),
      slot_for(when, level)
    );
  }
}
elapsed=     0 when=            10 xor=            10 masked.ilog2= 5 level=0 slot=10
elapsed=     0 when=           100 xor=           100 masked.ilog2= 6 level=1 slot=1
elapsed=    60 when=            70 xor=           122 masked.ilog2= 6 level=1 slot=1
elapsed=     0 when=          4200 xor=          4200 masked.ilog2=12 level=2 slot=1
elapsed=    64 when=           100 xor=            36 masked.ilog2= 5 level=0 slot=36
elapsed=  4096 when=          4200 xor=           104 masked.ilog2= 6 level=1 slot=1
elapsed=  4160 when=          4200 xor=            40 masked.ilog2= 5 level=0 slot=40
elapsed=     0 when= 1099511627776 xor= 1099511627776 masked.ilog2=40 level=5 slot=0
elapsed=68719476736 when= 1099511627776 xor= 1168231104512 masked.ilog2=40 level=5 slot=0

第三行说明层号看的是对齐,不是距离:从 60 到 70 只差 10 毫秒,但跨过了 64 这条边界,两者第 1 组不同(0 和 1),所以进第 1 层,而不是第 0 层。最后两行的 ilog2 是 40,超出了 MAX_DURATION,走「Fudge」分支,直接放进最顶层。

用异或而不是减法,是因为槽号就是 tick 的位段:只要更高的组都相同,slot_for(when, level) 取出的槽一定在当前这一圈里,并且在 elapsed 所在的槽之后。

📝NOTE
没有蠢问题

问: 前面说 elapsed 不一定等于现在,只在驱动醒来处理时才往前走。插入时拿一个落后的 elapsed 选层,会不会把定时器放进一个「已经过去」的槽,要等一整圈才触发?

答: 不会。Wheel::insert 只在 when <= elapsed 时拒绝插入,返回 Elapsed,调用方 reregister 就地 fire。when > elapsed 时,选出的槽在 elapsed 这一圈里、在 elapsed 的槽之后,槽的起点不晚于 when。就算 when 已经早于现在,也只是这个槽的起点早于 now:驱动下次 park 时 when.saturating_sub(now) 是 0,走 park_timeout(0),醒来立刻 process,Wheel::poll 看到槽的起点不晚于 now,当场处理。驱动正 park 在别处时,reregister 发现新的 when 比 next_wake 早,会先 unpark 它。落后的只是 elapsed 这个记账值,定时器不会因此多等。

找下一个到期的槽:occupied 位图

时间轮从不扫描空槽。Level::next_occupied_slot 把位图循环右移到「现在所在的槽的下一个」,再数末尾的 0:

fn next_occupied_slot(&self, now: u64) -> Option<usize> {
    if self.occupied == 0 {
        return None;
    }

    // Add the +1 offset for the `now_slot` to ignore the slot that `now` fits in,
    // since it's the farthest timer that could appear from `now`.
    // ...
    let now_slot = ((now / slot_range(self.level)) % LEVEL_MULT as u64) as usize + 1;
    let occupied = self.occupied.rotate_right(now_slot as u32);
    let zeros = occupied.trailing_zeros() as usize;
    let slot = (zeros + now_slot) % LEVEL_MULT;

    Some(slot)
}

now 所在的槽排到最后:低层里 level_for 不会把定时器放进它(那样 when 和 elapsed 在这一层的组相同,会去更低的层),只有最顶层会出现这种情况,而那时它代表的是一整圈之后。

📝NOTE
冷知识:那个 + 1 是 1.54.0 才加的

1.54.0 之前,这一行是 let now_slot = (now / slot_range(self.level)) as usize;,从 now 所在的槽本身开始找。低层没问题,那个槽总是空的;顶层不是。#8334 的说明记录了它怎么被发现:一个服务里有一个超过 2^36 毫秒的长 sleep,运行 12 天后,周期 1 到 15 秒的 interval 任务全部不再运行。PR 的分析是:时间轮走到 12 天(第 4 层一整圈,2^30 毫秒)时,新的短 sleep 和 interval 也被放进了第 5 层;那个长定时器占着第 5 层第 0 槽,而这个槽正好被当成 now 所在的槽来查,于是它被选为最近的槽,后面真正更早的槽被跳过。release 构建里,被跳过的条目没有下放,之后删除时找错了链表,把指针弄乱了。

修复就是现在的写法:now_slot 先对 64 取模再加 1,把 now 所在的槽排到最后。同一个 PR 也把这个修改复制到了 time_alt 的时间轮里。

Level::next_expiration 再把槽号换算成时刻:

let level_range = level_range(self.level);
let slot_range = slot_range(self.level);

// Compute the start date of the current level by masking the low bits
// of `now` (`level_range` is a power of 2).
let level_start = now & !(level_range - 1);
let mut deadline = level_start + slot as u64 * slot_range;

if deadline <= now {
    // A timer is in a slot "prior" to the current time. ...
    debug_assert_eq!(self.level, super::NUM_LEVELS - 1);

    deadline += level_range;
}

注意这里的 deadline 是槽的起点,不是槽里某个定时器的 when。第 1 层第 1 槽的 deadline 是 64,哪怕里面的定时器要到 100 才到期。

Wheel::next_expiration 从第 0 层往上找,返回第一个有槽的层。这样做是对的,因为低层的槽都在高层下一个槽之前(debug 断言 no_expirations_before 检查这一点)。pending 链表非空时,它直接返回 deadline = elapsed,表示「马上处理」。

推进:取出整个槽,到期的进 pending,没到期的下放

驱动醒来后调 Wheel::poll(now):只要下一个槽的 deadline 不晚于 now,就处理这个槽,并把 elapsed 推到这个 deadline;没有可处理的槽了,就把 elapsed 推到 now。每次先看 pending,有就返回一个给调用方去 fire。

处理一个槽在 process_expiration:

let mut entries = self.take_entries(expiration);
let deadline = if now == u64::MAX {
    now
} else {
    expiration.deadline
};

while let Some(item) = entries.pop_back() {
    // ...

    // Try to expire the entry; this is cheap (doesn't synchronize) if
    // the timer is not expired, and updates registered_when.
    match unsafe { item.mark_pending(deadline) } {
        Ok(()) => {
            // Item was expired
            self.pending.push_front(item);
        }
        Err(expiration_tick) => {
            let level = level_for(expiration.deadline, expiration_tick);
            unsafe {
                self.levels[level].add_entry(item);
            }
        }
    }
}

先用 take_slot 把整条链表拿下来、清掉 occupied 位,再逐个处理。注释说明了原因:最顶层的定时器可能被放回同一个槽(见后文「超出范围」),边遍历边放回会死循环。

mark_pending 读定时器真实的到期时间(存在状态字段里,可能被 reset 往后推过),不晚于 deadline 就 CAS 成「待触发」,进 pending;否则返回真实时间,用新的 elapsed(即这个槽的起点)重新 level_for,放进更低的层。这就是下放(cascade)。

手算一遍

elapsed = 0 时插入三个定时器,when 分别是 10、100、4200。按上一节的算法,它们分别落在第 0 层第 10 槽、第 1 层第 1 槽、第 2 层第 1 槽。假设驱动每次都恰好在 deadline 醒来:

步next_expiration 找到deadline处理elapsed
1第 0 层第 10 槽0 + 10×1 = 10when=10,到期,进 pending,fire10
2第 1 层第 1 槽0 + 1×64 = 64when=100 > 64,level_for(64, 100) → 第 0 层第 36 槽64
3第 0 层第 36 槽64 + 36 = 100到期,fire100
4第 2 层第 1 槽0 + 1×4096 = 4096when=4200 > 4096 → 第 1 层第 1 槽4096
5第 1 层第 1 槽4096 + 1×64 = 4160when=4200 > 4160 → 第 0 层第 40 槽4160
6第 0 层第 40 槽4160 + 40 = 4200到期,fire4200

第 2、4、5 步的层号和槽号,就是上面实验输出里 (64, 100)、(4096, 4200)、(4160, 4200) 三行。

两点观察:

  • 定时器从第 k 层到 fire,最多下放 k 次。 每次下放只是从一条链表摘下、挂到另一条,O(1)。插入、删除也是 O(1)。
  • 下放也要醒一次。 第 2、4、5 步没有任何任务被唤醒,但驱动必须在这些时刻醒来,因为 park 超时取的是槽的起点。在这个例子里,4200 那个定时器单独会让驱动醒 3 次,只有最后一次唤醒了任务。如果驱动醒得晚(比如 now 直接是 5000),poll 的循环会在一次调用里把第 4、5、6 步走完,因为每一步都用槽的 deadline 而不是 now 做判断。
❗IMPORTANT
动脑筋:sleep(64ms) 和 sleep(5000ms),线程各醒几次?

M2 Max、rustc 1.99.0。用暂停的时钟(start_paused,见本页最后一节)新建一个 current_thread 运行时,里面只跑一个 sleep;用 on_thread_unpark 在每次醒来时打印 tokio 时钟(从运行时创建算起):

let rt = tokio::runtime::Builder::new_current_thread()
  .enable_time()
  .start_paused(true)
  .on_thread_unpark(|| println!("醒来,tokio 时钟 = {}ms", ...))
  .build()
  .unwrap();
rt.block_on(sleep(Duration::from_millis(ms)));

ms 分别是 64 和 5000,各打印几行、每行是第几毫秒?

提示:用上面的 level_for 和 slot_for 手算,每次醒来的时刻是槽的起点。答案在本页末尾。

pending 链表和批量唤醒

到期的定时器先进 pending,而不是直接 fire,是因为 fire 要调 waker,而调 waker 不能持有驱动的锁(waker 可能是用户代码,可能再去 reset 一个定时器、抢同一把锁)。

Handle::process_at_time 在锁里逐个取 pending,fire 把状态改成已触发并拿出 waker,攒进一个 WakeList(容量 NUM_WAKERS = 32);攒满就放锁、全部唤醒、再拿锁继续:

while let Some(entry) = lock.wheel.poll(now) {
    debug_assert!(unsafe { entry.is_pending() });

    // SAFETY: We hold the driver lock, and just removed the entry from any linked lists.
    if let Some(waker) = unsafe { entry.fire(Ok(())) } {
        waker_list.push(waker);

        if !waker_list.can_push() {
            // Wake a batch of wakers. To avoid deadlock, we must do this with the lock temporarily dropped.
            drop(lock);

            waker_list.wake_all();

            lock = self.inner.lock();
        }
    }
}

放锁期间,别的线程可能 drop 一个还在 pending 里的 Sleep。所以进 pending 时 registered_when 被设成 STATE_DEREGISTERED,Wheel::remove 看到这个值就去 pending 里摘,而不是去某层某槽里找。

📝NOTE
冷知识:pending 链表被删掉过,一个多月后又恢复了

2024 年 6 月的 #6584(1.39.0)去掉过 pending。PR 的理由是:先把整个槽摘下来、再逐个挪进 pending,等于把链表遍历两遍,大量定时器同时到期时会有缓存未命中,而且整个过程都持着锁。它的做法是给每个槽记一个计数:槽链表只从头部插入、从尾部 pop_back,处理前就知道最多弹几次,不先摘下整个槽也不会死循环,pending 就可以不要了。

1.39.0 发布后,issue #6714 报告 quinn 的测试在 linked_list.rs 的断言上 panic,回滚 #6584 就好了。#6715 把它整个撤回,随 1.39.1 发布。所以现在仍是本页这种「先 take_entries 摘下整个槽,到期的进 pending」的写法。

函数开头还有一个防御:now 比 elapsed 小时(注释提到 Windows 宿主上的 Linux 虚拟机里 Instant 会倒退),直接把 now 当成 elapsed,时间轮不会倒着走。

超出范围的定时器

MAX_DURATION 是 2^36 毫秒,约 2.18 年,但 API 允许更长的 sleep:

  • sleep(duration) 先用 safe_delay 把时长截到 30 年(注释:1000 年在 macOS 上会溢出,100 年在 FreeBSD 上会溢出)。
  • 时刻换成 tick 后再截到 MAX_SAFE_MILLIS_DURATION,即 u64::MAX - 2,最大的两个值留给状态标记。

超过 2^36 的定时器,level_for 一律放进第 5 层,槽号仍按 when 的第 30–35 位取。这时第 5 层像一个环形缓冲区:next_expiration 算出的 deadline 不晚于 now 时,加一整圈(level_range)。槽到期时 mark_pending 失败,重新 level_for 还是第 5 层、同一个槽,于是放回去,等下一圈。一个 30 年的 sleep 会这样绕十几圈,直到剩余时间落进范围,再正常下放。实验最后两行就是这种情况:elapsed 从 0 走到 2^36,when = 2^40 仍然落在第 5 层第 0 槽。

驱动:用时间轮算 park 超时

时间轮本身不会动,推动它的是 Driver::park_internal。worker 要 park 时,经驱动栈调到这里(时间驱动套在 IO 驱动外面,见驱动栈与 park):

fn park_internal(&mut self, rt_handle: &driver::Handle, limit: Option<Duration>) {
    let handle = rt_handle.time();
    let mut lock = handle.inner.lock();

    assert!(!handle.is_shutdown());

    let next_wake = lock.wheel.next_expiration_time();
    lock.next_wake =
        next_wake.map(|t| NonZeroU64::new(t).unwrap_or_else(|| NonZeroU64::new(1).unwrap()));

    drop(lock);

    match next_wake {
        Some(when) => {
            let now = handle.time_source.now(rt_handle.clock());
            // Note that we effectively round up to 1ms here - this avoids
            // very short-duration microsecond-resolution sleeps that the OS
            // might treat as zero-length.
            let mut duration = handle
                .time_source
                .tick_to_duration(when.saturating_sub(now));

            if duration > Duration::from_millis(0) {
                if let Some(limit) = limit {
                    duration = std::cmp::min(limit, duration);
                }

                self.park_thread_timeout(rt_handle, duration);
            } else {
                self.park.park_timeout(rt_handle, Duration::from_secs(0));
            }
        }
        None => {
            if let Some(duration) = limit {
                self.park_thread_timeout(rt_handle, duration);
            } else {
                self.park.park(rt_handle);
            }
        }
    }

    // Process pending timers after waking up
    handle.process(rt_handle.clock());
}
时间轮调用方给的上限实际 park
有定时器,还差 d 毫秒(d > 0)无park_timeout(d)
有定时器,还差 d 毫秒limitpark_timeout(min(limit, d))
有定时器,已经到期任意park_timeout(0),只轮询一次 IO
没有定时器无park(),一直睡到 IO 事件或 unpark
没有定时器limitpark_timeout(limit)

now 是向下取整的毫秒,when 是整数毫秒,差值也是整数毫秒:只要还没到期,至少 park 1 毫秒,哪怕真实剩余时间只有几十微秒。注释说这相当于「向上取整到 1ms」,避免操作系统把极短的超时当成 0。

next_wake 记下「驱动承诺最晚什么时候醒」。别的线程插入一个更早的定时器时,reregister 比较新的 when 和 next_wake,更早才 unpark 驱动,让它重新算超时;不更早就不打扰。

醒来后(不管是超时到了、IO 事件、还是被 unpark)一律 process:取当前 tick,调 process_at_time。

关闭

Driver::shutdown 先置 is_shutdown,再 process_at_time(u64::MAX):所有槽都被视为到期。这时 process_expiration 用 u64::MAX 做 mark_pending 的上限,所有定时器直接进 pending、被 fire,不再逐层下放;Wheel::poll 也不推进 elapsed,因为中途放锁唤醒时,level_for 还要靠旧的 elapsed 找到没处理完的定时器。之后再 poll 这些 Sleep 会 panic,提示运行时正在关闭。

📝NOTE
冷知识:关闭时直接到期,是 1.51.5 才改的

以前关闭也走正常路径:Wheel::poll(u64::MAX) 一个槽一个槽地推进,每个槽用槽的起点做 mark_pending,真实 tick 还很远的定时器被放回第 5 层,等下一圈。issue #8569 报告:如果一个 deadline 极大的定时器(用 sleep_until 或 timeout 构造;sleep 有 30 年上限,不会触发)在关闭时还登记着,比如它的 future 被运行时外面的代码持有,关闭就要空转好几秒 CPU。报告里 macOS/aarch64 上一个这样的定时器让关闭花了 2.18 秒;Linux/x86_64 上,shutdown_timeout(10ms) 也花了 2.28 秒。

修复 #8570 的说明算了账:这种定时器要转最多 2^28 圈。改法就是本节描述的:now == u64::MAX 时,process_expiration 用 u64::MAX 做 mark_pending 的上限,槽里的条目直接到期,不下放,不放回第 5 层,elapsed 也不动。首个包含它的版本是 1.51.5。

时钟暂停时

开了 test-util 并且时钟被暂停(start_paused = true 或 time::pause()),park_thread_timeout 不真睡:先 park_timeout(0) 看一眼 IO,如果没有被唤醒,就把时钟直接拨快 duration。所以暂停时钟下 sleep(1 小时) 立刻返回,详见 Sleep 的一生里的实验。

动脑筋答案

完整程序(start_paused 需要 test-util feature):

use std::sync::Mutex;
use tokio::time::{sleep, Duration, Instant};

static START: Mutex<Option<Instant>> = Mutex::new(None);

fn main() {
  for ms in [64u64, 5000] {
    let rt = tokio::runtime::Builder::new_current_thread()
      .enable_time()
      .start_paused(true)
      .on_thread_unpark(|| {
        let start = START.lock().unwrap().unwrap();
        println!("  醒来,tokio 时钟 = {}ms", (Instant::now() - start).as_millis());
      })
      .build()
      .unwrap();

    rt.block_on(async {
      *START.lock().unwrap() = Some(Instant::now());
      println!("sleep({ms}ms)");
      sleep(Duration::from_millis(ms)).await;
    });
  }
}

M2 Max、rustc 1.99.0、--release,连跑 3 次,输出完全相同:

sleep(64ms)
  醒来,tokio 时钟 = 64ms
sleep(5000ms)
  醒来,tokio 时钟 = 4096ms
  醒来,tokio 时钟 = 4992ms
  醒来,tokio 时钟 = 5000ms

暂停的时钟下 park 不真睡,时钟直接拨快 park 超时那么多,所以每次醒来的时刻正好是槽的起点,结果是确定的。

  • 64:elapsed = 0,0 ^ 64 | 63 = 127,ilog2 是 6,进第 1 层;slot_for(64, 1) = 1,槽的起点是 64,正好等于 when。醒一次,mark_pending 直接成功。放在第 1 层不一定要下放,只要 when 落在槽的起点上。
  • 5000:5000 >> 12 = 1,进第 2 层第 1 槽,起点 4096。第一次醒在 4096,level_for(4096, 5000):4096 ^ 5000 = 904,ilog2(904 | 63) = 9,第 1 层;(5000 >> 6) % 64 = 14,起点 4096 + 14 × 64 = 4992。第二次醒在 4992,4992 ^ 5000 = 8,第 0 层第 8 槽,起点 4992 + 8 = 5000。第三次醒来才 fire。三次里有两次只是在下放。

小结

  • 6 层、每层 64 个槽、精度 1 毫秒;第 k 层每槽 64^k 毫秒,顶层整圈 2^36 毫秒,约 2.18 年。
  • 层号由 elapsed ^ when 的最高位决定(| 63 后取 ilog2 / 6),看的是对齐而不是距离;槽号就是 when 在这一层的 6 位。
  • 每层一个 u64 位图,循环右移加 trailing_zeros 找下一个非空槽;槽的到期时刻是槽的起点。
  • 高层槽到期时整条链表取下,真实时间已到的进 pending,没到的按新 elapsed 重新选层,逐层下放。下放本身也要驱动醒一次。
  • 驱动 park 前用最近的槽起点算超时,醒来后处理到期定时器,waker 每攒 32 个就放锁唤醒一次。
  • 超过 2^36 毫秒的定时器放在顶层,一圈圈地转,直到进入范围。

💬 关联讨论

发起讨论
加载中...