(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
原来整个办公室只有前台一本约会簿,谁要记一个提醒、划掉一个提醒,都得去前台排队(全局时间轮和它的锁)。现在每个员工桌上一本台历(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 怎么传给编译器,见源码地图。
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 的 worker | push_remote_timer:拿 synced 锁推进 inject_timers,再 notify_parked_remote 叫醒一个睡着的 worker |
注意第一种情况:登记时不插入时间轮。登记队列里的定时器要等这个 worker 下次 park 才进时间轮,而 park 之前 worker 本来也不会去看时间轮,所以不耽误。
问: 任务在 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);
- 登记队列里的定时器逐个插入本地时间轮;已经过期的直接进唤醒队列。
- 取走全局
inject_timers。take_remote_timers用的是try_lock,抢不到就算了,注释说另一个 worker 正在取。于是远程登记的定时器进了第一个抢到锁的 worker 的时间轮。 - 处理取消队列(下一节)。
- 唤醒队列不空,说明有任务马上要跑,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 前一次性取走、逐个从时间轮摘下。
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 不被阻塞。