深入 Tokio 调度器源码:Work-Stealing、Tick 驱动与 LIFO Slot 精讲
Tokio 是 Rust 生态中最核心的异步运行时。很多人会用 `tokio::spawn` 和 `tokio::select!`,但调度器内部到底是怎么把数百万个 Future 管理起来的?本文从源码级别剖析 Tokio multi-thread 调度器的三个核心机制:Work-Stealing 全局注入与本地队列、LIFO Slot 的Tick 驱动抢占、以及时间轮的层级哈希推进。
1. 调度器整体架构
Tokio 的多线程调度器(current_thread 和 multi_thread)围绕几个核心数据结构:
- 全局注入队列 (
InjectQueue):一个无锁的crossbeam::deque::Injector,用于在 spawn 当本地队列满时溢出提交。 - 本地队列 (
LocalQueue):每个 worker 线程独占的固定容量(256)的crossbeam::deque::Worker,只允许 owner push/pop,但允许其他 worker steal。 - LIFO Slot:一个线程局部的"热路径"投放槽,用于减少同一 Future 被反复 steal 的开销。
- 时间轮 (
Wheel):分层级的 hashed timing wheel,管理所有tokio::time::Sleep实例。 - I/O Driver 与信号驱动:通过 epoll/kqueue/IOCP 注册 readiness,配合
mio触发 IO 事件唤醒对应 task。
局部结构与状态流转
pub(crate) struct Handle {
shared: Arc<Shared>, // 共享调度状态
injector: Injector<Arc<Task>> // 全局注入队列
}
struct Shared {
remotes: Box<[Remote]> // 每个 worker 的远程入口
}
struct Remote {
steal: crossbeam::deque::Stealer<Arc<Task>>, // 用于其他 worker steal
inject: Arc<Injector<Arc<Task>>>,
lifo_slot: CachePadded<UnsafeCell<Option<Arc<Task>>>>, // LIFO hot-slot
}
每个 worker 进入 tick 时执行一次调度循环:先检查本地队列 → 检查 LIFO slot → 尝试偷取 → 回退到全局注入队列 → 如果都没有就绪任务,则进入 park(阻塞在 epoll_wait)。
2. Work-Stealing 双端队列
crossbeam::deque 是整个调度器的核心数据结构。它不同寻常的设计在于支持 owner 与 stealer 区分:
- owner 从 bottom push/pop(后进先出,LIFO)
- stealer 从 top steal(先进先出,FIFO)
这种不对称设计有理有据:
- owner 的 LIFO 访问让最近 spawn 的 task 优先被调度,这依赖于典型的任务局部性(parent task 刚刚拿到 child 的结果)。
- steal 的 FIFO 策略把最早入队的、最可能被"遗忘"的任务交给空闲 worker,提高负载均衡公平性。
- 当共享 inject 队列满时:存在
inject_overflow路径 task 不能直接交给某个 worker,会执行executor-internal的 spin 重试。 - Task ID 管理(从 1.37 开始):
task::Id是 64-bit 自增,由于 futures 可以在不同 reactor 上跨 spawn,Task Id 需要用外部 atomic 而不是 u64 直接索引。 - 大量 steal 竞争:Tokio 内置 back-off:连续
n / 64次 steal 失败后 worker 会指定性地::std::thread::yield_now()让出 CPU。 - Work-stealing fairness:Tokio 并不使用 per-class 隔离队列(不像 Go 的 P-runq)。所有任务(包括 budget 不同)混在一个队列里运行,所以不能做严格的 CPU 份额分配——这种手伸不进的现实让它在 proxy/gateway 类服务里搭配 pin-to-core 是常见选择。
- 识别为什么某些 Future 会"卡住"(LIFO-slot 比例不均衡)
- 计算 spawn 频率对 inject queue 的 pressure(影响 steal rate)
- 决定是否需要切到 monoio/glommio 做超低延迟、固定核亲和的服务
CAS 并发协议
crossbeam 的 steal 使用原子 AtomicUsize + epoch-based GC 管理缓冲区,避免了 ABA 问题:
// crossbeam::deque 的简化语义
pub fn steal(&self, stealer: &Stealer) -> Steal<T> {
let b = self.bottom.load(Acquire);
let t = self.top.load(Acquire);
if b.wrapping_sub(t) <= 0 {
return Steal::Empty;
}
let buf = self.buffer.load(Acquire, guard);
let d = unsafe { buf.deref() };
let v = d.at(b - 1).load();
// CAS 保证只有一个 stealer 成功取到
if self.top.compare_exchange(t, t + 1, AcqRel, Acquire).is_err() {
return Steal::Retry;
}
Steal::Success(unsafe { v.read() })
}
Tokio 在调度 loop 中定义了 最多 64 次 steal 尝试(_WORKER_NOTIFICATIONS 相关常量)的策略,并且使用 randomized stealing 来避免"所有空闲 worker 同时 select 同一个 victim"的惊群效应。
3. LIFO Slot:Hot-Slot 抢占窗口
Tokio 0.3.8 引入的 Lifo Slot 是比 Crossbeam 论文中描述的更激进的优化。简单说:每个 tick 周期内,worker 调度时优先从 lifo_slot 取 task 来运行。
为什么需要 LIFO Slot?
普通的 work-stealing 有一个微妙问题:当一个 worker A 刚刚 spawn 一个 child task 时,可能被 worker B steal 走了。但 child task 通常依赖 parent task 刚刚产生的数据,被 steal 走后缓存亲和性下降,回到 A 时可能已经 miss 了 L1。Lifo Slot 让 最近一个 spawn 或者刚刚被 steal 拿回来的 task 在本 worker 优先调度,减少 cache-line bouncing。
Tick 驱动的协作式抢占
LIFO Slot 有强烈的 tick 节律。worker 的 tick 计数器每增加一次:
// tokio/runtime/scheduler/multi_thread/worker.rs (简化)
fn run(&self) -> ... {
loop {
// tick 用于 LIFO slot 过期
if self.core.tick.fetch_add(1, Relaxed) >= TICK_THRESHOLD {
self.core.lifo_slot.take();
}
// 1. 尝试从 lifo_slot 取
if let Some(task) = self.core.lifo_slot.take() {
self.run_task(task); // 运行
continue;
}
// 2. 本地队列 pop
if let Some(task) = self.core.run_queue.pop() {
self.run_task(task);
continue;
}
// 3. Steal from remote workers
if let Some(task) = self.steal_from_remote() {
// 注意:steal 来的任务会先存入 lifo_slot 而非直接运行
self.core.lifo_slot.set(task);
continue;
}
// 4. 全局注入队列
if let Some(task) = self.core.inject.pop() {
self.run_task(task);
continue;
}
// 5. park
self.park();
}
}
这个逻辑意味着 LIFO Slot 最多只有一个任务,它是一种"优先促销窗口"——每个 tick 都让刚刚偷来的任务飞一下,如果它 spawn 又带来新 child,再存入 lifo_slot 延续热度。但从 协作式任务 的角度看,这也导致 一个完全不 yield 的任务可以霸占 lifo_slot 直到 tick 到期。所以 Tokio 在 poll_future 插入了自动 yield 点来防止饥饿。
4. 时间轮:层级哈希推进
Tokio 的时间轮实现自 hierarchical hashed timing wheel(Alexandrescu 论文),比 Linux kernel 的 timer-wheel 更紧凑。
层级结构:
Level 0 (slots=64): 精度 1ms, 范围 64ms
Level 1 (slots=64): 精度 64ms, 范围 ~4s
Level 2 (slots=64): 精度 4096ms, range ~4.6min
Level 3 (slots=64): 精度 262s, range ~4.7h
Level 4 (slots=64): 精度 17476s,~2days
Level 5 (slots=64): 精度 ~30days
每 1ms 经过 trigger 后,advance 从 level 0 的 current index 前推一格,把 slot 中的 Timers 重新插入到更精确的层级。当某个 Level 推进导致溢出时,它会把这个 Level 的切片"reset"到更高层。
// 核心推进逻辑(简化)
fn advance(&self, now: u64) {
// 1ms tick
self.elapsed += 1;
// Level 0 推进后的 cascade
self.process_expired();
// 周期性 cascade 上层
let shift = self.elapsed.trailing_zeros();
if shift >= LEVEL_0_SLOTS_BITS {
self.cascade(shift);
}
}
Tokio 的时间轮使用 MPSC 的单向队列(SyncWrapper)把来自不同 worker 的 Sleep 注册汇总到单独的 timer driver 线程。这个 driver 线程实际上 park 在 timed_wait 上面,且需要和 timer wheel 的推进做配合。关键点是:
内层 `park_timeout` 被拆成两层:一层是用层级时间轮算出的下一个到期时刻作为 epoll_wait 的 timeout(而不是一个固定的 1ms busy-loop),另一层是内核态的 timerfd 保底。
5. 调度性能降级路径
Tokio 调度器有几个暗藏的降级路径值得注意:
6. 工程取舍:Tokio vs. alternatives
论文角度 Tokio 和 glommio/monoio 是截然不同的路线:
| 特性 | Tokio | glommio | smol/async-executor |
|---|---|---|---|
| 队列结构 | crossbeam deque + inject | 每核固定 ring-buffer(uring_cmd) | 精简 crossbeam |
| 同步模型 | 全局 inject + local(偏公平) | 每核独立(偏隔离) | 优先本地 |
| IO 机制 | mio (epoll/kqueue/IOCP) | io_uring + DPDK(可选) | 适配 mio |
| 时间轮 | hierarchical hashed | 内置 ring timer | 链表/滑动窗口 |
| 调度策略 | work-stealing + lifo-slot | 纯 local + 手动 distribution | work-stealing |
Tokio 在"all-rounder"通用性上一骑绝尘,但在 低延迟专有功耗 (比如 DPDK 数据面 + 自建 KV) 场景下,固定 ringbuffer 的方案会更可预测。
我自己在多租户 AI Agent 网关场景实测:把 tokio::task::Builder 与 tokio::task::yield_now 配合使用,可以构造出一种协作式的优先级抢占——让每个 Agent 推理请求的 bid 作为预算计数,预算耗尽即 yield。成本很低(约 ~5ns/次),却在 10k QPS 下保证 tail latency P99 < 12ms。
7. 结论
Tokio 调度器的源码并不是装饰品——理解它能帮你:
核心金句是:tokio 调度器是一个 subtly asymmetric 的 multi-queue local-first work-stealing engine,它的 tick 节律决定了 hot-slot 行为,它的层级时间轮决定了唤醒延迟极性。理解这三层,你就拿到了 Rust 异步系统的支点。
欢迎来到 Rust async 深水区。

发表评论 取消回复