深入 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)

这种不对称设计有理有据:

  1. owner 的 LIFO 访问让最近 spawn 的 task 优先被调度,这依赖于典型的任务局部性(parent task 刚刚拿到 child 的结果)。
  2. steal 的 FIFO 策略把最早入队的、最可能被"遗忘"的任务交给空闲 worker,提高负载均衡公平性。
  3. 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 调度器有几个暗藏的降级路径值得注意:

    1. 当共享 inject 队列满时:存在 inject_overflow 路径 task 不能直接交给某个 worker,会执行 executor-internal 的 spin 重试。
    2. Task ID 管理(从 1.37 开始):task::Id 是 64-bit 自增,由于 futures 可以在不同 reactor 上跨 spawn,Task Id 需要用外部 atomic 而不是 u64 直接索引。
    3. 大量 steal 竞争:Tokio 内置 back-off:连续 n / 64 次 steal 失败后 worker 会指定性地 ::std::thread::yield_now() 让出 CPU。
    4. Work-stealing fairness:Tokio 并不使用 per-class 隔离队列(不像 Go 的 P-runq)。所有任务(包括 budget 不同)混在一个队列里运行,所以不能做严格的 CPU 份额分配——这种手伸不进的现实让它在 proxy/gateway 类服务里搭配 pin-to-core 是常见选择。
    5. 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 调度器的源码并不是装饰品——理解它能帮你:

      • 识别为什么某些 Future 会"卡住"(LIFO-slot 比例不均衡)
      • 计算 spawn 频率对 inject queue 的 pressure(影响 steal rate)
      • 决定是否需要切到 monoio/glommio 做超低延迟、固定核亲和的服务

      核心金句是:tokio 调度器是一个 subtly asymmetric 的 multi-queue local-first work-stealing engine,它的 tick 节律决定了 hot-slot 行为,它的层级时间轮决定了唤醒延迟极性。理解这三层,你就拿到了 Rust 异步系统的支点。


      欢迎来到 Rust async 深水区。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部