Rust in Everything
基于 tokio 1.54.1

(unstable)time_alt

主线位置:和前两页相同,是任务主线的「挂起 → 唤醒」。区别在于谁持有时间轮、谁来触发:从「持有驱动的那个线程」变成「每个 worker 自己」。
flowchart TB
  poll[Sleep 第一次 poll] --> q{在本运行时的 worker 上?}
  q -->|是| rq[推进本 worker 的登记队列]
  q -->|否| inj[推进全局 inject_timers,叫醒一个 worker]
  rq --> before[park 前:登记队列和 inject 插进本地时间轮]
  inj --> before
  before --> park[按本地时间轮算超时,park]
  park --> after[醒来:take_expired 进 WakeQueue]
  after --> wake[wake_all]
  drop[Sleep drop] --> cq[推进所属时间轮的取消队列]
  cq --> before
💡TIP
打个比方:每人一本台历

原来整个办公室只有前台一本约会簿,谁要记一个提醒、划掉一个提醒,都得去前台排队(全局时间轮和它的锁)。现在每个员工桌上一本台历(Core 里的 Wheel),只有坐在这张桌前的人能翻。

员工手头忙时,新的提醒先写在便利贴上贴在桌角(登记队列),等他停下来歇口气之前,才誊进台历(park 前 process_registration_queue)。外面的访客没有桌子,只能把便利贴投进大厅的公共收件箱(inject_timers),哪个员工先去拿,就归谁。要划掉一个提醒,不能走过去翻别人的台历,只能往那张桌子的信箱里塞一张「作废」条(取消队列),桌主下次歇口气前统一处理。

比方到这里为止:台历跟的不是人,是桌子。员工去开长会(block_in_place)时,桌子连同台历交给另一个人继续用;Core 在线程之间交接时,时间轮也跟着走。

怎么打开

源码在 runtime/time_alt/,整个模块只在 cfg(all(tokio_unstable, feature = "rt-multi-thread")) 下编译。打开它的是 Builder::enable_alt_timer:

/// Enables the alternative timer implementation, which is disabled by default.
///
/// The alternative timer implementation is an unstable feature that may
/// provide better performance on multi-threaded runtimes with a large number
/// of worker threads.
///
/// This option only applies to multi-threaded runtimes. Attempting to use
/// this option with any other runtime type will have no effect.
/// ...
#[cfg(all(tokio_unstable, feature = "time", feature = "rt-multi-thread"))]
pub fn enable_alt_timer(&mut self) -> &mut Self {
    self.enable_time();
    self.timer_flavor = TimerFlavor::Alternative;
    self
}

「其他运行时无效」是硬写的:构建 current_thread 运行时时,build_current_thread_runtime_components 里有一行 cfg.timer_flavor = TimerFlavor::Traditional,强制改回传统实现。

这个实现由提交 73d733a3「time: add alternative timer for better multicore scalability (#7467)」加入,目标就是文档里说的:worker 很多时的多核扩展性。tokio_unstable 怎么传给编译器,见源码地图。

📝NOTE
冷知识:在 time_alt 之前,tokio 试过把全局时间轮切成几份

issue #6504 收集了「定时器多时驱动锁竞争严重」的报告,列出的想法之一是每个运行时线程一个分片。2024 年 5 月的 #6534(1.38.0)照此实现:时间轮按 worker 数切成几份,每份一把锁,登记时用线程本地的随机数挑一份。一年后的 #7226(1.45.0)把它回滚,说明只有一句:分片让竞争变多,造成了可测量的性能退化。

time_alt 的设计讨论 issue #7384 回顾了这次尝试:驱动 park 时要把所有分片逐个锁一遍才能算出最早的定时器,在热路径上锁多把锁,比全局锁的竞争还贵。所以这一次换了思路,时间轮放进 Core,主人访问不加锁,别人只往队列里投。#7467 在 2025 年 11 月合入,随 1.49.0 发布。

全局部分几乎是空的

打开后,create_time_driver 不再创建时间驱动,只返回 TimeDriver::EnabledAlt(io_stack):park 直接落到 IO 驱动上,中间没有时间轮。时间句柄是 Driver::new_alt 建的 Inner::Alternative,只剩一个 is_shutdown 标志(和 test-util 的 did_wake),没有 Mutex<InnerState>;对它调 lock() 会走到 unreachable!。全局时间句柄只负责时间换算(TimeSource)。

时间轮搬进了每个 worker 的 Core(worker.rs 第 133 行),类型是 LocalContext:

pub(crate) struct LocalContext {
    pub(crate) wheel: Wheel,
    pub(crate) registration_queue: RegistrationQueue,
    pub(crate) canc_tx: cancellation_queue::Sender,
    pub(crate) canc_rx: cancellation_queue::Receiver,
}

Core 同一时刻只在一个线程上,拿着 Core 就能直接改这个时间轮,不用锁(Core 怎么在线程间交接,见 multi_thread 总览)。全局只多了一个 Vec:调度器共享状态 Synced 里的 inject_timers,给不在 worker 上创建的定时器用。

时间轮本身(time_alt/wheel/)是 runtime/time/wheel/ 的一份拷贝:同样 6 层、64 槽、同一个 level_for。差别是节点类型和三处行为:

  • 没有 pending 链表,到期的直接放进调用方给的 WakeQueue(take_expired)。
  • 节点的到期 tick 创建后不变,没有「真实 tick 和登记 tick 不同」的情况。下放时直接比较 deadline > expiration.deadline。
  • insert 要求 deadline > elapsed,并在插入前把本时间轮的取消队列发送端登记到节点上。

节点:Arc<Entry>

传统实现的节点内嵌在 Sleep 里,不分配内存。time_alt 的节点是堆上的 Entry,用 Arc 共享:

struct State {
    cancelled: bool,
    woken_up: bool,
    waker: Option<Waker>,
    cancel_tx: Option<Sender>,
}

pub(crate) struct Entry {
    /// The intrusive pointer used by [`super::cancellation_queue`].
    cancel_pointers: linked_list::Pointers<Entry>,

    /// The intrusive pointer used by any of the following queues:
    ///
    /// - [`Wheel`]
    /// - [`RegistrationQueue`]
    /// - [`WakeQueue`]
    /// ...
    extra_pointers: linked_list::Pointers<Entry>,

    /// The tick when this entry is scheduled to expire.
    deadline: u64,

    state: Mutex<State>,
    // ...
}

两组链表指针:extra_pointers 依次用于登记队列 → 时间轮 → 唤醒队列,同一时刻只在其中一个里,注释写明了这条路线;cancel_pointers 单独给取消队列用,因为取消可能发生在节点还挂在时间轮上的时候。状态不再是一个原子 u64,而是每个节点自己的一把 Mutex,锁的范围只有这一个定时器。

登记:先进本地队列,park 前才插入

Sleep 的懒登记不变:第一次 poll 时调 Timer::new。Alternative 分支走 time_alt::Timer::new:

pub(crate) fn new(handle: scheduler::Handle, deadline: u64) -> Self {
    let entry = with_current_temp_local_context(&handle, |ctx| match ctx {
        Some(TempLocalContext::Running { registration_queue }) => {
            let entry = EntryHandle::new(deadline);
            unsafe { registration_queue.push_front(entry.clone()) }
            entry
        }
        #[cfg(feature = "rt-multi-thread")]
        Some(TempLocalContext::Shutdown) => panic!("{RUNTIME_SHUTTING_DOWN_ERROR}"),

        _ => {
            let entry = EntryHandle::new(deadline);
            push_from_remote(&handle, entry.clone());
            entry
        }
    });

    Timer { entry }
}

with_current_temp_local_context 判断当前线程是不是这个运行时的 worker、手里有没有 Core:

当前线程去向
本运行时的 worker,持有 Core推进 Core 里的登记队列:一个普通链表,不拿任何锁
本运行时的 worker,但运行时正在关闭panic
其他:block_on 所在线程、阻塞池线程、别的运行时、正在 block_in_place 的 workerpush_remote_timer:拿 synced 锁推进 inject_timers,再 notify_parked_remote 叫醒一个睡着的 worker

注意第一种情况:登记时不插入时间轮。登记队列里的定时器要等这个 worker 下次 park 才进时间轮,而 park 之前 worker 本来也不会去看时间轮,所以不耽误。

📝NOTE
没有蠢问题

问: 任务在 worker W1 上第一次 poll Sleep,定时器进了 W1 的登记队列。之后任务被 W2 偷走,在 W2 上再 poll 这个 Sleep,定时器会跟着搬到 W2 吗?

答: 不会。登记只在第一次 poll 时发生一次。之后 Sleep 的 timer 已经是 Some,再 poll 走的是 EntryHandle::poll:拿节点自己的锁,换一个 waker,返回 Pending,不碰任何队列。节点仍在 W1 的登记队列或时间轮里,到期时由 W1 在 park 前后 wake,waker 再把任务调度到哪个 worker 都行。drop 也一样:cancel_tx 指向 W1 那个时间轮的取消队列,不管 drop 发生在哪个线程。所以「定时器依赖登记它的 worker」,说的是第一次 poll 时所在的那个 Core,和任务后来跑在哪里无关。

park 前后:本 worker 处理自己的定时器

worker 主循环一章引用 park_internal 时省略了两段,就是 time_alt 的部分。park 前调 maintain_local_timers_before_parking:

util::time_alt::process_registration_queue(
    &mut time_cx.registration_queue,
    &mut time_cx.wheel,
    &time_cx.canc_tx,
    &mut wake_queue,
);
util::time_alt::insert_inject_timers(
    &mut time_cx.wheel,
    &time_cx.canc_tx,
    handle.take_remote_timers(),
    &mut wake_queue,
);
util::time_alt::remove_cancelled_timers(&mut time_cx.wheel, &mut time_cx.canc_rx);
let should_yield = !wake_queue.is_empty();

let next_timer = util::time_alt::next_expiration_time(&time_cx.wheel, &handle.driver);
  1. 登记队列里的定时器逐个插入本地时间轮;已经过期的直接进唤醒队列。
  2. 取走全局 inject_timers。take_remote_timers 用的是 try_lock,抢不到就算了,注释说另一个 worker 正在取。于是远程登记的定时器进了第一个抢到锁的 worker 的时间轮。
  3. 处理取消队列(下一节)。
  4. 唤醒队列不空,说明有任务马上要跑,park 时长改成 0;否则取「调用方给的上限」和「本地时间轮下一个到期」中较小的那个。

然后照常 park。多线程调度器里只有一个 worker 能拿到 IO 驱动,其他 worker 停在条件变量上;Parker::park_timeout 在拿不到驱动、时长不为 0 时走 park_condvar(Some(duration)),所以每个 worker 都能睡到自己的下一个定时器为止。

醒来后调 maintain_local_timers_after_parking:取当前 tick,take_expired 把到期的定时器放进 WakeQueue,再逐个 wake:拿节点自己的锁,没被取消就置 woken_up,取出 waker,放锁后调用。Sleep 再被 poll 时,EntryHandle::poll 看到 woken_up 就返回 Ready。

这两段都在 park_internal 里,而 park_internal 不只在没活干时调用:维护每 event_interval(默认 61)个 tick 会 park_yield 一次,同样走这里。所以一个忙碌但不阻塞的 worker,也会定期处理自己的定时器。

取消:送回所属的时间轮

Timer 的 Drop 调 EntryHandle::cancel:

pub(crate) fn cancel(&self) {
    let mut lock = self.entry.state.lock();
    if !lock.cancelled {
        lock.cancelled = true;
        let waker = lock.waker.take();
        let cancel_tx = lock.cancel_tx.take();
        drop(lock);

        if let Some(cancel_tx) = cancel_tx {
            // Safety: we can guarantee that `self` is not in any cancellation queue
            // because the `self.cancelled` was just set to `true`.
            unsafe {
                cancel_tx.send(self.clone());
            }
        }

        drop(waker);
    }
}

cancel_tx 是插入时间轮时登记的,指向那个时间轮的取消队列(cancellation_queue::Sender,里面是一个 Arc<Mutex<链表>>)。drop 可能发生在任何线程上,所以要经过这个队列,由时间轮的主人在下次 park 前一次性取走、逐个从时间轮摘下。

📝NOTE
冷知识:cancel 里那个 drop(waker) 是 1.51.5 才加的

#8550 之前,cancel 不动节点里存的 waker。它一直留在节点上,直到 worker 做定时器维护、把这个已取消的定时器摘下来时才被析构,而那时 worker 正持有对调度器 Core 的可变借用。PR 说明指出:如果析构这个 waker 又在同一个运行时上登记一个定时器,就要再借一次 Core,直接 panic。修复是在 cancel 里当场 take 出 waker,放掉节点锁之后 drop。首个包含它的版本是 1.51.5。传统实现的 clear_entry 在同一批版本里修了一个同类问题:持着驱动锁析构 waker 会死锁(见 Sleep 的一生)。

  • 节点已经在时间轮里:推进取消队列。锁是这个时间轮自己的,不是全局的。
  • 节点还在登记队列或 inject_timers 里:cancel_tx 还是 None,只置 cancelled。等它被插入时,register_cancel_tx 看到已取消,返回 false,insert 直接放弃。
  • 已经触发过:woken_up 为真时 register_cancel_tx 同样拒绝,wake 也不会再调 waker。

reset 没有「往后推用 CAS」的快路径:Timer::reset 的 Alternative 分支直接新建一个 Timer 覆盖旧的,旧的 drop 时走上面的取消流程。

和传统实现对比

传统time_alt
时间轮全局一个,Mutex 保护每个 worker 一个,放在 Core 里,不加锁
节点TimerShared 内嵌在 Sleep 里,不分配Arc<Entry>,每次登记分配一次
状态一个 AtomicU64每节点一把 Mutex
worker 上登记拿全局锁,立即插入推进本地登记队列,park 前插入
非 worker 线程登记同上拿调度器 synced 锁推进 inject_timers
reset 往后推一次 CAS新建节点,取消旧节点
取消拿全局锁拿节点锁,推进所属时间轮的取消队列
谁来触发当时拿到驱动的那个线程时间轮所在的 worker,在 park 或 park_yield 前后
适用运行时current_thread 和 multi_thread只有 multi_thread

两套实现并存的原因,从源码能看到的是:time_alt 依赖 multi_thread 的 Core,current_thread 用不了;它在 tokio_unstable 后面,默认关闭,Builder 文档请用户到 issue 7745 反馈使用体验。所以传统实现目前仍是唯一的稳定实现。

实验:worker 被卡住时

time_alt 把「谁触发」绑定到了 worker 上,这会带来一个传统实现没有的现象。程序在同一个 4 worker 运行时里,分别用两种实现跑两件事:

// 需要 RUSTFLAGS="--cfg tokio_unstable"
fn run(alt: bool) {
  let mut b = tokio::runtime::Builder::new_multi_thread();
  b.worker_threads(4).enable_all();
  if alt {
    b.enable_alt_timer();
  }
  let rt = b.build().unwrap();
  let name = if alt { "alt        " } else { "traditional" };

  rt.block_on(async move {
    // 1. block_on 的线程不是 worker:sleep 走 push_remote_timer
    let t = Instant::now();
    sleep(Duration::from_millis(30)).await;
    println!("{name} block_on 里 sleep(30ms): {}ms", t.elapsed().as_millis());

    // 2. 一个 worker 被卡住时,登记在它那里的定时器
    let h = tokio::spawn(async {
      let t = Instant::now();
      // 先 spawn 一个会卡住线程 300ms 的任务:它进当前 worker 的 LIFO 槽,
      // 等本任务返回 Pending 后就在同一个 worker 上运行
      tokio::spawn(async {
        std::thread::sleep(Duration::from_millis(300));
      });
      sleep(Duration::from_millis(20)).await;
      t.elapsed().as_millis()
    });
    println!("{name} 所在 worker 被卡 300ms 时 sleep(20ms): {}ms", h.await.unwrap());
  });
}

fn main() {
  run(false);
  run(true);
}
traditional block_on 里 sleep(30ms): 33ms
traditional 所在 worker 被卡 300ms 时 sleep(20ms): 23ms
alt         block_on 里 sleep(30ms): 33ms
alt         所在 worker 被卡 300ms 时 sleep(20ms): 308ms
  • 第 1 项两者一样:远程登记的定时器进了某个空闲 worker 的时间轮,正常触发。
  • 第 2 项:任务 A 在 worker W 上 spawn 了阻塞任务 B(进 W 的 LIFO 槽,别人偷不走),然后在 sleep(20ms) 上挂起。传统实现里,定时器在全局时间轮里,另一个 worker 拿着驱动,20 毫秒后照常触发、唤醒 A,A 被别的 worker 运行。time_alt 里,A 的定时器还在 W 的登记队列里,W 被 B 占住 300 毫秒,既没 park 也没维护,这个定时器没人处理,直到 B 结束、W 去 park 时才被发现已过期。

在 async 代码里阻塞线程本来就是错误用法,但这个实验说明了 time_alt 的约束:定时器的精度取决于登记它的 worker 多久 park 一次,而不是整个运行时有没有空闲线程。

小结

  • time_alt 需要 --cfg tokio_unstable,用 Builder::new_multi_thread().enable_alt_timer() 打开;current_thread 上被强制改回传统实现。
  • 全局时间驱动退化成只管时间换算,时间轮搬进每个 worker 的 Core,结构和传统时间轮相同。
  • 节点是 Arc<Entry>,每节点一把锁;登记先进本地队列,park 前才插入;非 worker 线程登记进全局 inject_timers。
  • 取消通过所属时间轮的取消队列送回给它的主人处理;reset 是新建加取消。
  • 到期定时器由时间轮所在的 worker 在 park 或 park_yield 前后触发。这去掉了全局锁,也让定时器依赖这个 worker 不被阻塞。

💬 关联讨论

发起讨论
加载中...