Tokio 源码深度解析

第5章 多线程 Scheduler 与工作窃取

作者 杨艺韬 · 14,335 字 · 发布于 · 更新于

本章要点

  • Tokio multi_thread scheduler 由三个核心 struct 支撑:Core(每 worker 独占的状态)、Shared(全体 worker 共享)、Worker(worker 线程的容器)
  • Worker 主循环的"三条腿":从本地队列取、从其他 worker 偷、park 等通知
  • 本地队列是一个 256 大小的环形缓冲区,用一个宽整型 atomic 同时编码两个头指针(real head + steal head)——在有 AtomicU64 的平台上就是一个 AtomicU64 装两个 u32,这个位打包是 Tokio 性能的关键之一
  • 工作窃取的随机起点:避免所有 idle worker 同时盯着同一个繁忙 worker,用 fastrand 从随机位置开始扫描
  • LIFO slot 是每 worker 一个的"最近一次调度"槽位——在 worker 上刚 spawn 或刚被唤醒的 Task 立刻被同一个 worker 接着 poll,省掉一次入队 / 出队
  • global_queue_interval 决定 worker 每转多少圈就强制从全局队列取一个——这是保证"全局 spawn 不被本地 spawn 饿死"的公平性机制。multi_thread 下它默认是自调优的(Stats::tuned_global_queue_interval,种子 61、钳在 2..=127),不是固定的 31
  • park / transition_to_parked / transition_from_parked 三个状态转换是 worker 线程从"忙"到"空闲休眠"的完整协议

5.1 三个核心 struct:Core、Shared、Worker

Tokio multi-thread scheduler 的设计可以从"三个 struct 的职责切分"入手快速抓到精髓:Core 是线程私有的——每个 worker 自己的本地状态;Shared 是全局共享的——所有 worker 共用一份只读/原子状态;Worker 是执行单元——把 Core 和 Shared 组合起来跑 Task。这种"私有数据 + 共享数据 + 执行单元"的三分法是并发系统设计的经典模式——Linux 进程调度、Go runtime、ECS 游戏引擎都在用类似结构。理解了这个切分,后面所有调度细节都能找到归属——本地队列在 Core、全局队列在 Shared、主循环在 Worker。

打开 tokio/src/runtime/scheduler/multi_thread/worker.rs,Tokio 1.40 的多线程调度器主要由三个嵌套的 struct 构成(Worker 在 tokio/src/runtime/scheduler/multi_thread/worker.rs:91、Core 在 tokio/src/runtime/scheduler/multi_thread/worker.rs:103、Shared 在 tokio/src/runtime/scheduler/multi_thread/worker.rs:148)。字段原样贴出(删去了源码里的 doc 注释和 shutdown_cores 上的一个 #[allow] 属性,行尾注释是本专栏加的):

// 来源:tokio-rs/tokio · tokio/src/runtime/scheduler/multi_thread/worker.rs (tokio-1.40.0)

// 每个 worker 独占、不对外共享的状态
struct Core {
    tick: u32,                                       // 主循环每转一圈自增 1,用于周期性检查
    lifo_slot: Option<Notified>,                     // LIFO slot:最近一次在本 worker 上被调度的任务
    lifo_enabled: bool,                              // 是否启用 LIFO 优化
    run_queue: queue::Local<Arc<Handle>>,            // 本地任务队列(256 容量)
    is_searching: bool,                              // "我现在在找活儿"的标志
    is_shutdown: bool,                               // shutdown 标志
    is_traced: bool,                                 // tracing 相关
    park: Option<Parker>,                            // park/unpark 器
    stats: Stats,                                    // 调度统计
    global_queue_interval: u32,                      // 多少次后强制去全局队列取
    rand: FastRand,                                  // 工作窃取的随机源
}

// 所有 worker 共享的状态
pub(crate) struct Shared {
    remotes: Box<[Remote]>,                          // 每个 worker 对应一个 Remote(里面是 Steal 句柄)
    pub(super) inject: inject::Shared<Arc<Handle>>,  // 全局注入队列
    idle: Idle,                                      // idle worker 管理
    pub(crate) owned: OwnedTasks<Arc<Handle>>,       // 所有 Task 的"全局名册"
    pub(super) synced: Mutex<Synced>,                // 需要互斥的共享状态
    shutdown_cores: Mutex<Vec<Box<Core>>>,           // shutdown 时暂存 Core 用
    pub(super) trace_status: TraceStatus,
    config: Config,
    pub(super) scheduler_metrics: SchedulerMetrics,
    pub(super) worker_metrics: Box<[WorkerMetrics]>, // 每 worker 的 metrics
    _counters: Counters,
}

// worker 线程持有的容器
pub(super) struct Worker {
    handle: Arc<Handle>,                             // 指回 runtime Handle(进而指回 Shared)
    index: usize,                                    // 这是第几个 worker
    core: AtomicCell<Core>,                          // 用 AtomicCell 让 Core 可以交给另一个线程
}

这三个 struct 的关系:

graph TB
    subgraph "Worker 0"
        W0[Worker] --> C0[Core 0]
    end
    subgraph "Worker 1"
        W1[Worker] --> C1[Core 1]
    end
    subgraph "Worker N"
        WN[Worker] --> CN[Core N]
    end
    W0 -.handle.-> S[Shared]
    W1 -.handle.-> S
    WN -.handle.-> S
    S --> R[remotes Box]
    S --> I[inject 全局队列]
    S --> O[owned 全局 Task 名册]
    C0 -.steal.-> R
    C1 -.steal.-> R
    CN -.steal.-> R

为什么要把状态拆成独占 + 共享?

  • Core 在 worker 内独占访问:tick、lifo_slot、run_queue 这些每个 worker 频繁读写,如果共享就需要锁——变成性能毒药
  • Shared 跨 worker 共享:remotes(偷任务的句柄)、inject(全局队列)、owned(全局 Task 名册)必须对所有 worker 可见
  • Core 用 AtomicCell 包装:平时 worker 独占,但 block_in_place 时要把 Core 交给另一个线程——当前线程把 Core 放回 worker.core(tokio/src/runtime/scheduler/multi_thread/worker.rs:432),新线程在 run 开头用 AtomicCell::take 原子地取走它

这种"按访问模式分拆"的数据结构设计是高性能并发系统的通用做法。Linux scheduler 的 rq / task_struct 分拆、Go runtime 的 P / M / G 分拆,本质上都是同一种思路:把频繁访问的数据隔离到独占域、把偶尔共享的数据集中在共享域,用不同的同步原语保护。


5.2 Worker 主循环:run 函数的骨架

Worker 主循环是调度器的"心脏"——每个 worker 线程从 spawn 开始就一直在这个循环里转、直到 runtime shutdown 才退出。主循环的骨架非常干净:取 task → poll task → 根据 poll 结果决定下一步。复杂性藏在"取 task"这一步——它要依次尝试 LIFO slot、本地队列、全局队列、工作窃取,每一步失败才往下走。读下面代码时请把注意力集中在这个优先级顺序上——它决定了 Tokio 的调度特性。

打开 worker.rs,run 函数(tokio/src/runtime/scheduler/multi_thread/worker.rs:471)是每个 worker 线程的入口点。下面是节选(省掉了开头那段 debug_assertions 下的 AbortOnPanic 守卫,并给关键行加了一句本专栏的旁注):

// 来源:tokio/src/runtime/scheduler/multi_thread/worker.rs:471
// (略去 :472-487:一个只在 #[cfg(debug_assertions)] 下生效的 AbortOnPanic 守卫,
//   worker 线程 panic 时直接 abort 进程,便于测试里定位)
fn run(worker: Arc<Worker>) {
    let core = match worker.core.take() {
        Some(core) => core,
        None => return,
    };

    worker.handle.shared.worker_metrics[worker.index]
        .set_thread_id(thread::current().id());

    let handle = scheduler::Handle::MultiThread(worker.handle.clone());

    crate::runtime::context::enter_runtime(&handle, true, |_| {
        let cx = scheduler::Context::MultiThread(Context {
            worker,
            core: RefCell::new(None),
            defer: Defer::new(),
        });

        context::set_scheduler(&cx, || {
            let cx = cx.expect_multi_thread();
            assert!(cx.run(core).is_err());        // ← run 只会以 Err 返回
            cx.defer.wake();
        });
    });
}

这 20 行代码做了 5 件事:

  1. 从 AtomicCell 里取出 Core——如果已经被别人取走(说明另一个线程正在运行这个 worker),直接返回
  2. 设置 worker 的 thread_id—— 用于 metrics 和 tracing
  3. enter_runtime:把当前线程 thread-local 设置成"我是 Tokio 的 worker"
  4. set_scheduler:再设一层 thread-local,标明"当前 scheduler 是 multi_thread"
  5. 调 cx.run(core)—— 真正的调度主循环。assert!(...is_err()) 表示"它不应该 Ok 返回"——返回 Result 只是为了能用 ? 提前退出:要么 shutdown,要么 Core 在 poll 期间被 block_in_place 交给了别的线程

真正的干活在 cx.run(core) 里——这个函数在同一文件里(tokio/src/runtime/scheduler/multi_thread/worker.rs:524),大致是:

// 简化自 worker.rs
impl Context {
    fn run(&self, mut core: Box<Core>) -> RunResult {
        while !core.is_shutdown {
            // 计数 + 周期性 maintenance(每 event_interval 圈拉一次 driver)
            core.tick();
            core = self.maintenance(core);

            // 三条腿找一个任务
            if let Some(task) = core.next_task(&self.worker) {
                core = self.run_task(task, core)?;
                continue;
            }
            if let Some(task) = core.steal_work(&self.worker) {
                core = self.run_task(task, core)?;
                continue;
            }

            // 没活了,park
            core = self.park(core);
        }

        // shutdown 路径
        core.pre_shutdown(&self.worker);
        self.worker.handle.shutdown_core(core);
        Err(())
    }
}

这就是 worker 主循环的骨架:

  • 第一条腿:next_task —— 优先从本地队列取(偶尔从全局队列取一个)
  • 第二条腿:steal_work —— 本地没了,去偷其他 worker 的
  • 第三条腿:park —— 偷也偷不到,休眠等通知

三条腿的优先级是精心设计的——本地优先减少跨核访问的 cache miss;偷前先确认本地真空,避免无谓的跨核原子操作;只有彻底空才 park(park 涉及系统调用,代价高)。

tick 字段的玄机

注意 Core 里的 tick: u32 字段——主循环每转一圈(在取任务之前)自增 1。它是整个 scheduler 的"时间片秒表":

  • tick % global_queue_interval == 0 时优先查全局队列(5.3 节详解)
  • tick % event_interval == 0 时调 park_timeout(core, Some(Duration::from_millis(0))) 非阻塞拉一次 I/O 事件(tokio/src/runtime/scheduler/multi_thread/worker.rs:685)——这让繁忙 worker 也会周期性处理 I/O,不会"只顾 CPU 忘了 I/O"
  • 也用于周期性 metrics 提交

为什么用 u32? 环绕一圈是 43 亿个 tick——按每 tick 一微秒算,70 分钟才环绕。而且这字段全是 % N 用途,环绕不影响正确性。省 4 字节,cache line 就多出位置。

这种"能用更小类型就用更小"的思考贯穿 Tokio 源码。现代 CPU cache line 通常是 64 字节,热点 struct 塞进尽量少的 cache line 能减少访存次数——Tokio hot struct 的字段排列大体按这个原则。


5.3 next_task:本地队列 + 全局队列的配合

看 next_task(tokio/src/runtime/scheduler/multi_thread/worker.rs:785)的原样代码:

// 来源:tokio/src/runtime/scheduler/multi_thread/worker.rs:785
fn next_task(&mut self, worker: &Worker) -> Option<Notified> {
    if self.tick % self.global_queue_interval == 0 {
        self.tune_global_queue_interval(worker);
        worker.handle.next_remote_task()
            .or_else(|| self.next_local_task())
    } else {
        let maybe_task = self.next_local_task();
        if maybe_task.is_some() {
            return maybe_task;
        }
        if worker.inject().is_empty() {
            return None;
        }
        let cap = usize::min(
            self.run_queue.remaining_slots(),
            self.run_queue.max_capacity() / 2,
        );
        let n = usize::min(
            worker.inject().len() / worker.handle.shared.remotes.len() + 1,
            cap,
        );
        let n = usize::max(1, n);
        let mut synced = worker.handle.shared.synced.lock();
        let mut tasks = unsafe { worker.inject().pop_n(&mut synced.inject, n) };
        let ret = tasks.next();
        self.run_queue.push_back(tasks);
        ret
    }
}

两条分支:

分支 A:tick % global_queue_interval == 0(周期性)

优先从全局队列取一个,取不到才 fallback 本地。这是反饥饿设计——如果只用本地队列,全局队列里的 Task(比如外部线程用事先 clone 好的 Handle 调 handle.spawn(...) 塞进来的)可能永远被饿死。强制每隔 global_queue_interval 圈拉一次全局,就保证了全局队列里的 Task 有一个上界内的等待时间。

这个间隔是多少? 分两种情况(tokio/src/runtime/scheduler/multi_thread/stats.rs:33、tokio/src/runtime/scheduler/current_thread/mod.rs:122):

  • current_thread:常量 DEFAULT_GLOBAL_QUEUE_INTERVAL = 31,固定不变
  • multi_thread:自调优。tuned_global_queue_interval(tokio/src/runtime/scheduler/multi_thread/stats.rs:55)用 TARGET_GLOBAL_QUEUE_INTERVAL(200µs,tokio/src/runtime/scheduler/multi_thread/stats.rs:33)除以"单次 poll 耗时的 EWMA",再 clamp(2, MAX_TASKS_POLLED_PER_GLOBAL_QUEUE_INTERVAL)(tokio/src/runtime/scheduler/multi_thread/stats.rs:66,上界常量 127 在 tokio/src/runtime/scheduler/multi_thread/stats.rs:36)。EWMA 的初值取 TARGET_GLOBAL_QUEUE_INTERVAL / TARGET_TASKS_POLLED_PER_GLOBAL_QUEUE_INTERVAL(tokio/src/runtime/scheduler/multi_thread/stats.rs:45),后者是 61(tokio/src/runtime/scheduler/multi_thread/stats.rs:39),所以冷启动时算出来正好是 61——注释里写明 61 是"之前的默认值",127 是"之前默认值的 2 倍"。这里有一处上游文档与代码不一致:tokio/src/runtime/mod.rs:274 的模块文档说这个目标是 10ms,而 tokio/src/runtime/scheduler/multi_thread/stats.rs:33 里的常量 TARGET_GLOBAL_QUEUE_INTERVAL 实际是 200µs——本专栏一律按代码写
  • 两者都可以用 Builder::global_queue_interval(n) 显式固定(tokio/src/runtime/builder.rs:873;n 为 0 会 panic,见 tokio/src/runtime/builder.rs:874 的 assert!(val > 0, ...),PR #6445)

next_task 里那句 self.tune_global_queue_interval(worker) 就是在每次走分支 A 时重算一遍这个间隔——只有新旧值相差超过 2 才真的改(u32::abs_diff(self.global_queue_interval, next) > 2,tokio/src/runtime/scheduler/multi_thread/worker.rs:1021),避免频繁抖动。

分支 B:tick % global_queue_interval != 0(常态)

  1. 先调 next_local_task(tokio/src/runtime/scheduler/multi_thread/worker.rs:840,函数体只有一行 self.lifo_slot.take().or_else(|| self.run_queue.pop())——先取 LIFO slot 再 run_queue.pop())——走 fast path
  2. 本地没了,先看全局队列是不是真的空(is_empty 无锁检查)
  3. 全局有 Task,一次性批量拉一些(pop_n)而不是一个一个拉——减少 mutex 争抢

批量拉的数量算法非常精细(tokio/src/runtime/scheduler/multi_thread/worker.rs:809):

  • 上限 cap 取"本地队列剩余槽位"和"本地队列总容量的一半"两者中的小者——不把本地队列塞满(给后续 spawn 留空间)
  • 数量取 全局队列长度 / worker 数 + 1,再和 cap 取小者——每 worker 均摊一份

这是一个典型的"公平调度"工程细节:一个空的 worker 一次不会拉走所有全局任务(那样下次其他 worker 又会抢),而是拉自己那份。


5.4 steal_work:本地空了,去偷邻居的

Work-stealing 是这一章(甚至整个专栏)最需要在脑子里建立清晰画面的算法。"本地空了就去偷邻居一半的 task"——这句话背后是并发算法领域数十年积累的智慧。1990 年代中期 MIT 的 Cilk 系统是它的经典代表,后来 Java ForkJoinPool、Go runtime、.NET TPL、Rayon 都采纳了类似算法。Tokio 的 work-stealing 在这个传统之上做了自己的优化——随机起点、打包偷、限制并发搜索者——本节把这些优化点一一拆开。

当本地 + 全局都空,worker 就进入"搜索"模式,去其他 worker 那里偷任务。原样代码(tokio/src/runtime/scheduler/multi_thread/worker.rs:849):

// 来源:tokio/src/runtime/scheduler/multi_thread/worker.rs:849
fn steal_work(&mut self, worker: &Worker) -> Option<Notified> {
    if !self.transition_to_searching(worker) {
        return None;
    }

    let num = worker.handle.shared.remotes.len();
    let start = self.rand.fastrand_n(num as u32) as usize;

    for i in 0..num {
        let i = (start + i) % num;
        if i == worker.index {
            continue;
        }

        let target = &worker.handle.shared.remotes[i];
        if let Some(task) = target
            .steal
            .steal_into(&mut self.run_queue, &mut self.stats)
        {
            return Some(task);
        }
    }

    worker.handle.next_remote_task()
}

四个关键点:

1. 先 transition_to_searching

尝试把自己标记为"搜索中"。但 Tokio 限制并发搜索 worker 的数量——Idle::transition_worker_to_searching 的第一道门是 if 2 * state.num_searching() >= self.num_workers { return false; }(tokio/src/runtime/scheduler/multi_thread/idle.rs:106),即最多约一半(⌈worker_count / 2⌉ 个)worker 可以同时 searching。为什么?

搜索本身不免费:它要读其他 worker 的队列 head/tail——即使没偷到,也产生了 cross-core atomic load 的 cache-coherence 开销。如果所有 worker 都在搜索空队列,整个系统被"搜索开销"拖死。限制并发搜索是自我保护机制。

如果 transition_to_searching 失败,直接返回 None——worker 会走向 park。

2. fastrand_n 产生随机起点

注意这行:

let start = self.rand.fastrand_n(num as u32) as usize;

随机起点是工作窃取算法的灵魂。如果每个 worker 都从 0 开始扫描,那 worker 0 会被反复偷(所有 idle worker 都先问 worker 0),而 worker N-1 很少被偷——造成偷窃不均衡。

随机起点打破这个模式:每个 worker 每次搜索从一个随机位置开始扫描,统计上所有 worker 被偷的概率均匀。

起点由 self.rand.fastrand_n(num as u32) 产生(tokio/src/runtime/scheduler/multi_thread/worker.rs:856)。随机化是工作窃取调度的老传统:Go runtime 偷任务时也从随机位置开始轮询各个 P,Cilk 则是每次随机挑一个受害者。

随机 ≠ 真随机:fastrand 的伪随机就够用

这里的随机不需要密码学安全。实际上 Tokio 用一个极简的 xor-shift 伪随机生成器:

// 来源:tokio/src/util/rand.rs:77 (tokio-1.40.0)
// 实现 xorshift64+:两条 32 位 xorshift 序列相加;移位三元组 [17,7,16]
pub(crate) struct FastRand { one: u32, two: u32 }

impl FastRand {
    pub(crate) fn fastrand_n(&mut self, n: u32) -> u32 {
        // 作用类似 fastrand() % n,但更快(Lemire 的乘法再右移)
        let mul = (self.fastrand() as u64).wrapping_mul(n as u64);
        (mul >> 32) as u32
    }

    fn fastrand(&mut self) -> u32 {
        let mut s1 = self.one;
        let s0 = self.two;

        s1 ^= s1 << 17;
        s1 = s1 ^ s0 ^ s1 >> 7 ^ s0 >> 16;

        self.one = s0;
        self.two = s1;

        s0.wrapping_add(s1)
    }
}

几条指令就产生下一个数——注意连"取余"都被换成了 Lemire 的"乘法再右移 32 位"(源码注释直接给了 lemire.me 的那篇文章 链接)。对于"给偷窃起点选个位置"这个场景,统计分布够均匀就行——源码注释里说它能通过 TestU01 的 SmallCrush 套件(tokio/src/util/rand.rs:26),这个保证已经绰绰有余。

把"选最优算法"的功夫花在值钱地方,把"够用就行"的地方放过——这是成熟系统的标志。本专栏后面你会看到 Tokio 还有类似的"够用即可"哲学出现在多处,比如时间轮的精度、metrics histogram 的桶数。

3. 自带 FastRand 而不是依赖 rand crate

为什么不用外部的 rand crate?因为 FastRand 是极简的 xorshift64+(源码注释里引用了 Marsaglia 的 Xorshift 论文,移位三元组按论文方法算得 [17,7,16],见 tokio/src/util/rand.rs:23)——每次生成只需要几条指令,而且不引入依赖。hot path 上用重度 RNG 会拖慢调度。Tokio 自带的 FastRand 是专门为此优化的。

4. 偷不到才 fallback 全局队列

所有 worker 都问过了还没偷到,调 next_remote_task 再查一次全局(tokio/src/runtime/scheduler/multi_thread/worker.rs:876)——可能这个时间窗里正好有新 Task 被 inject 进来。

如果最终依然 None,返回 None——worker 会 park。


5.5 queue.rs:本地队列的位打包魔法

Tokio 本地队列的实现是无锁数据结构的教科书级案例——用一个宽 atomic(head)加一个窄 atomic(tail)实现单生产者多消费者(SPMC)的定长环形队列。位打包的技巧让一次 CAS 就能原子地同时读写 head 的两个字段(steal 游标 + real head)。这种"硬塞到一个 word 里"的操作在系统编程里是硬核的性能优化、但代价是代码难读——本节把它拆到每一位都讲清。

Tokio 的本地队列是整个 scheduler 里最精巧的一段代码。它是一个 256 容量的环形缓冲区(LOCAL_QUEUE_CAPACITY 定义在 tokio/src/runtime/scheduler/multi_thread/queue.rs:63),但它的 head 指针用了双头打包技术:

// 来源:tokio/src/runtime/scheduler/multi_thread/queue.rs:63 (tokio-1.40.0)
// 平台有 AtomicU64 时:UnsignedShort = u32、UnsignedLong = u64
// 否则回退到:      UnsignedShort = u16、UnsignedLong = u32
#[cfg(not(loom))]
const LOCAL_QUEUE_CAPACITY: usize = 256;
const MASK: usize = LOCAL_QUEUE_CAPACITY - 1;

pub(crate) struct Inner<T: 'static> {
    head: AtomicUnsignedLong,       // 一个宽整型同时编码两个 UnsignedShort
    tail: AtomicUnsignedShort,      // 只有生产者写、多消费者读
    buffer: Box<[UnsafeCell<MaybeUninit<task::Notified<T>>>; LOCAL_QUEUE_CAPACITY]>,
}

pub(crate) struct Local<T: 'static> {
    inner: Arc<Inner<T>>,
}

pub(crate) struct Steal<T: 'static>(Arc<Inner<T>>);

关键设计:

  • Local:生产 + 本地消费接口。worker 自己通过 Local 推任务进、弹任务出
  • Steal:窃取接口。其他 worker 通过 Steal 从这个队列"偷"任务
  • head: AtomicUnsignedLong:一个宽整型 atomic——常见 64 位平台上是 AtomicU64,低 32 位是 "real head",高 32 位是 "steal head";不支持 AtomicU64 的平台上退化成 AtomicU32 装两个 u16。索引位宽故意比 256 所需的 8 位宽得多,为的是缓解 ABA、并区分"满"和"空"(见 tokio/src/runtime/scheduler/multi_thread/queue.rs:14 注释指向的 tokio#5041)

双头打包解决什么问题

传统的 work-stealing deque(如 Chase-Lev)有两个独立的原子变量:

  • top(消费端)由 thief 读写
  • bottom(生产端)由 owner 读写

Tokio 里对应的是 head 和 tail,这一对照样是分开的。它多出来的是消费端的第二个游标——窃取者要先"认领"一批任务、再把它们逐个复制走,复制期间这批槽位既不能被别的窃取者重复拿、也不能被生产者覆盖。Tokio 的做法:

  • 把"real head"和"steal head"塞进同一个 atomic
  • 一次 CAS 同时读写两者:窃取者第一次 CAS 只推进 real head、把 steal head 留在原处(认领),复制完再用第二次 CAS 让 steal head 追上 real head(tokio/src/runtime/scheduler/multi_thread/queue.rs:477-533)。两者不相等期间,别的窃取者直接放弃(tokio/src/runtime/scheduler/multi_thread/queue.rs:459),生产者按 tail - steal 计算容量,也不会写到这段槽位上

具体含义:

  • real head:真正"下一个要被消费的位置"
  • steal head:如果有 thief 正在偷,它声明的"我打算消费的位置"
  • 两者相等时,没人在偷;不相等时,区间 [steal_head, real_head) 是"有人正在偷走但还没完成"的任务

这个设计让 pop(owner 自己取)和 steal_into(thief 偷取)可以无锁并发执行——冲突在一次 CAS 的失败重试里自然解决。

pop:本地消费者的精巧路径

原样代码(tokio/src/runtime/scheduler/multi_thread/queue.rs:347):

// 来源:tokio/src/runtime/scheduler/multi_thread/queue.rs:347
pub(crate) fn pop(&mut self) -> Option<task::Notified<T>> {
    let mut head = self.inner.head.load(Acquire);

    let idx = loop {
        let (steal, real) = unpack(head);
        let tail = unsafe { self.inner.tail.unsync_load() };

        if real == tail {
            return None;
        }

        let next_real = real.wrapping_add(1);
        let next = if steal == real {
            pack(next_real, next_real)
        } else {
            assert_ne!(steal, next_real);
            pack(steal, next_real)
        };

        let res = self
            .inner
            .head
            .compare_exchange(head, next, AcqRel, Acquire);

        match res {
            Ok(_) => break real as usize & MASK,
            Err(actual) => head = actual,
        }
    };

    Some(self.inner.buffer[idx].with(|ptr| unsafe { ptr::read(ptr).assume_init() }))
}

这段代码的精华:

  • unsync_load() 读 tail:因为 tail 只能由 owner(本地 worker)写,所以在 owner 自己 pop 时可以用 unsynchronized load(比 atomic load 更快)
  • CAS 更新 head:如果没有 thief 介入(steal == real),一并推进两个 head;有 thief 介入则只推 real head
  • real as usize & MASK:MASK = LOCAL_QUEUE_CAPACITY - 1 = 255,环形缓冲用位与代替取模

hot path 的性能就是从这种对每一个字段的极致利用中攒出来的。

steal_into:偷取者的批量策略

原样(简化)(tokio/src/runtime/scheduler/multi_thread/queue.rs:394,内层的 steal_into2 在 tokio/src/runtime/scheduler/multi_thread/queue.rs:449):

// 来源:tokio/src/runtime/scheduler/multi_thread/queue.rs:394
pub(crate) fn steal_into(
    &self,
    dst: &mut Local<T>,
    dst_stats: &mut Stats,
) -> Option<task::Notified<T>> {
    let dst_tail = unsafe { dst.inner.tail.unsync_load() };
    let (steal, _) = unpack(dst.inner.head.load(Acquire));

    if dst_tail.wrapping_sub(steal) > LOCAL_QUEUE_CAPACITY as UnsignedShort / 2 {
        return None;          // 目标自己队列已经半满,不要再偷
    }

    let mut n = self.steal_into2(dst, dst_tail);
    if n == 0 { return None; }

    // 统计 + 把偷来的 n 个里的第一个直接返回,剩下的放进 dst 队列
    // ...
    Some(ret)
}

关键决策:

  • 预检查 dst 队列容量:如果我(thief worker)自己队列已经半满,不去偷。因为偷来放不下、偷少了不划算
  • 一次偷 n 个而不是 1 个:steal_into2 内部会偷被偷者队列现有任务数的一半——准确说是 n - n / 2,即向上取整的一半(tokio/src/runtime/scheduler/multi_thread/queue.rs:465),所以队列里只有 1 个任务时也能偷走 1 个。理论依据是工作窃取的"half-steal"策略——偷走一半让两边负载均衡

这就是 Tokio 为什么高效:不是单纯的"本地没就偷一个",而是每次偷一把,减少未来的偷窃频率。

偷不回头:被偷走的 Task 会再被偷吗?

一个精细问题:worker A 偷走了 worker B 的一批 Task 放进 A 的本地队列。这些 Task 会不会再被 worker C 偷走?

可以。一旦任务到了 A 的本地队列,它就是 A 队列的一部分——worker C 偷 A 时可能偷到它。但按机制推断不常发生(本专栏没有测量数据)——worker 刚偷完任务就去跑了,很快就会 pop 掉放队列前头的任务。到 C 有机会偷时,这些 Task 可能已经被 A 跑完了。

这种"偷的级联"理论上会发生,按上面的推理应当不多见——工作窃取的自然流动是从忙到闲,很少发生"A 偷完 B,C 又偷完 A" 的链式偷窃。就算真发生了,系统也不会错,只是会多几次 cache miss、性能稍差。

罕见路径不优化但也不出错——这是 Tokio 源码的常见哲学,也是性能敏感系统的工程美学之一。

push_back:生产者端

看 owner 把新任务塞进本地队列的代码(tokio/src/runtime/scheduler/multi_thread/queue.rs:136):

// 来源:tokio/src/runtime/scheduler/multi_thread/queue.rs:136
pub(crate) fn push_back(&mut self, tasks: impl ExactSizeIterator<Item = task::Notified<T>>) {
    let len = tasks.len();
    assert!(len <= LOCAL_QUEUE_CAPACITY);

    if len == 0 {
        return;
    }

    let head = self.inner.head.load(Acquire);
    let (steal, _) = unpack(head);
    let mut tail = unsafe { self.inner.tail.unsync_load() };

    if tail.wrapping_sub(steal) <= (LOCAL_QUEUE_CAPACITY - len) as UnsignedShort {
        // 有空间(这个空 if 是为了和 push_back_or_overflow 的结构对齐)
    } else {
        panic!()   // push_back 契约就是"调用方保证装得下",装不下直接 panic
    }

    for task in tasks {
        let idx = tail as usize & MASK;
        self.inner.buffer[idx].with_mut(|ptr| {
            unsafe {
                ptr::write((*ptr).as_mut_ptr(), task);
            }
        });
        tail = tail.wrapping_add(1);
    }

    self.inner.tail.store(tail, Release);
}

三个技术细节值得驻足:

  1. tail.wrapping_sub(steal):用 wrapping(环绕)减法算队列当前长度。tail 和 steal 都是 UnsignedShort(常见平台上是 u32),它们可以自然 wrap——环绕减法恰好计算出循环距离
  2. unsync_load 读 tail:owner 自己就是唯一写 tail 的人,无需 atomic load——省一条屏障
  3. store(tail, Release) 最后一步:所有任务写完后用 Release 发布新 tail——保证其他 worker(thief)看到新 tail 时,被索引的槽位已经可读

这个 release store 是写端唯一一次同步开销——一次 push 批量 N 个任务,只付一次 release。批量越大,平摊的同步开销越小。这就是为什么 5.3 节的 next_task 批量拉取全局队列——不是一次一个,是一次一批,就是在榨干这个 release 的价值。

容量溢出怎么办?

LOCAL_QUEUE_CAPACITY = 256。如果 worker 一直在 spawn 而没来得及消费,本地队列满了怎么办?

Tokio 的实际处理走的是同文件里的另一个入口 push_back_or_overflow(tokio/src/runtime/scheduler/multi_thread/queue.rs:185,装不下时转调 push_overflow,tokio/src/runtime/scheduler/multi_thread/queue.rs:250):

  • 检查本地队列是否能装下
  • 装不下 → overflow 到全局队列——把本地队列里一半的任务(const NUM_TASKS_TAKEN: UnsignedShort = (LOCAL_QUEUE_CAPACITY / 2) as UnsignedShort,tokio/src/runtime/scheduler/multi_thread/queue.rs:262,即 128 个;连同这次要 push 的这个共 129 个)移到 inject 队列,腾出空间
  • 这意味着 spawn 的最坏情况是"遇到本地队列满了,触发一次全局队列的 mutex lock"——而不是 panic 或丢任务

这是 Tokio 对"突发 spawn 密度"的天然保护。你可以安心在一个 async fn 里 spawn 几千个子任务,Tokio 会在本地 256 容量用完时自动 overflow 到全局,其他 worker 就能偷走全局的任务分担负载。不需要任何显式流控。


5.6 LIFO slot:给 spawn → .await 这个模式开绿灯

LIFO slot 是 Tokio 对一个极常见使用模式的专门优化——"刚 spawn 一个 task、立刻 await 它"。如果没有 LIFO slot,这个模式需要:spawn → 入队 → 调度器挑选 → 从队列取出 → poll。有了 LIFO slot:spawn → 放 slot → 下一次调度直接取 slot → poll。省了入队/出队的一对开销、而且因为在同一个 worker 上运行、CPU 缓存还是热的。Tokio 官方文档把 LIFO slot 称作"a heuristic for optimizing message-passing patterns"——它首先针对的是消息传递(A 唤醒 B 时,让 B 紧接着在同一个 worker 上跑),spawn 后立刻 await 是与之并列的受益场景。

Core 里有一个字段 lifo_slot: Option<Notified>(tokio/src/runtime/scheduler/multi_thread/worker.rs:112)。这是一个只能装一个 Task 的槽位,用途是给刚在本 worker 上被调度(spawn 或唤醒)的任务开绿灯。

典型场景

下面这段是读者示例,不是 Tokio 源码:

// 生产者模式:spawn 子任务后立刻 await 它
async fn producer_consumer() {
    let handle = tokio::spawn(async { compute() });
    let result = handle.await;    // 立刻等 handle
    use_result(result);
}

这段代码的调用序列:

  1. 调用 tokio::spawn——一个新 Task 被交给当前 worker 调度
  2. handle.await——当前 Task yield(返回 Pending)
  3. Worker 需要找下一个 Task

没有 LIFO slot 时:新 Task 进本地队列尾部,Worker 从本地队列 pop——拿到的是队列里最老的那个 Task(FIFO),不一定是刚 spawn 的子任务——于是要等一会儿。

有 LIFO slot 时:schedule_local(tokio/src/runtime/scheduler/multi_thread/worker.rs:1081)让新 Task 占住 LIFO slot,把原本待在 slot 里的那个挤到本地队列尾部(push_back_or_overflow)。当前 Task 返回 Pending 后,run_task 里的 LIFO 循环直接 core.lifo_slot.take()(tokio/src/runtime/scheduler/multi_thread/worker.rs:617),在同一份 coop 预算下接着 poll 它;回到主循环时 next_local_task(tokio/src/runtime/scheduler/multi_thread/worker.rs:840)——self.lifo_slot.take().or_else(|| self.run_queue.pop())——也是优先从 LIFO slot 取。于是刚 spawn 的任务立刻被执行。

不过 LIFO 也有乒乓饥饿风险:A 唤醒 B、B 又唤醒 A,两者会一直霸占 slot。Tokio 的对策是在 run_task 里计数——连续从 LIFO slot 取任务达到 MAX_LIFO_POLLS_PER_TICK = 3 次(常量在 tokio/src/runtime/scheduler/multi_thread/worker.rs:241,判定在 tokio/src/runtime/scheduler/multi_thread/worker.rs:653)就把 core.lifo_enabled 置 false,这一轮剩下的调度全部回落到 FIFO 本地队列;等这次 run_task 里的 LIFO 连跑循环发现 slot 空了、准备把 core 交还主循环时,同一处再调 reset_lifo_enabled(函数体在 tokio/src/runtime/scheduler/multi_thread/worker.rs:666)复位(tokio/src/runtime/scheduler/multi_thread/worker.rs:620)。Context::run 开头也有一次 reset_lifo_enabled(tokio/src/runtime/scheduler/multi_thread/worker.rs:527),但那是给"core 被别的线程偷走后重新拿到"兜底用的,不是每圈都走。另外协作式预算(coop)用光时,slot 里的任务也会被推回本地队列尾部。

为什么这是关键优化

spawn 子任务 + await 它这个模式极其常见——几乎所有 "fan-out" 并发代码都有这个形态。Tokio 的 LIFO slot 把这种模式的延迟从"好几个 poll 周期"压缩到"下一次 poll"。

LIFO slot 并不是后来补的补丁——它在 2019 年那轮 work-stealing 调度器改造里就已经是默认行为(见博客《Making the Tokio scheduler 10x faster》里"优化消息传递模式"一节)。真正后加的是关掉它的开关:Builder::disable_lifo_slot()(tokio/src/runtime/builder.rs:1019;PR #4936,随 Tokio 1.21.0 发布),至今仍是 tokio_unstable 门禁下的 API——它整个落在 tokio/src/runtime/builder.rs:911 起的那个 cfg_unstable! 块里——用它得带上 RUSTFLAGS="--cfg tokio_unstable"。

LIFO slot 和 work-stealing 的互动

LIFO slot 的任务不能被其他 worker 偷——它是本 worker 独占的。这保证"刚 spawn 的任务不会被远程 worker 意外拿走",但反过来也意味着它是唯一不可窃取的任务(Builder::disable_lifo_slot 的文档把这一点写成了该开关存在的理由,见 issue #4941)。

那 worker 要休眠时怎么办?答案是根本不会休眠:transition_to_parked 第一句就是 if self.has_tasks() || self.is_traced { return false; },而 has_tasks()(tokio/src/runtime/scheduler/multi_thread/worker.rs:896)正是 lifo_slot.is_some() || run_queue.has_tasks()——slot 里还有任务,就不允许 park。

真正会把 slot 里的任务倒进本地队列的是 block_in_place:它要把 Core 交给另一个线程,而 spawn_blocking 繁忙时可能迟迟没有线程接手这个 Core,slot 里的任务又不可窃取,所以先 core.lifo_slot.take() 再 push_back_or_overflow(tokio/src/runtime/scheduler/multi_thread/worker.rs:414-420)。这段逻辑是 Tokio 1.38.0 才补上的(PR #6596)。

disable_lifo_slot 什么时候用

绝大多数时候别关——LIFO slot 是净收益。官方文档给出的唯一场景是:

由于 LIFO slot 里的任务不能被其他 worker 窃取,当任务的 poll 时间偏长时,总吞吐反而会下降。如果观察到"任务的 scheduled 时间很高、但 runtime 整体利用率不足",可以试试这个选项(用 tokio-metrics 采集这项数据)。

除此之外,基准测试里想排除 LIFO 的影响也算一个理由。生产服务在没有 metrics 佐证前不要瞎调——它还是 unstable API。


5.7 park / unpark:worker 的休眠协议

没活干的时候 worker 不能空转(CPU 占用 100%),必须睡眠等待。唤醒也不能是"大家一起醒"(Thundering Herd),必须有控制。Tokio 的 park/unpark 协议解决的就是这两个问题:(1)如何让 worker 高效睡眠;(2)新 task 到达时如何精准唤醒一个 worker,而不是一次唤醒全部。这个协议和 Linux 内核里 futex、Go 调度器休眠 / 唤醒 M 的那套逻辑是同类机制——都是"多线程并发挂起 / 精准唤醒"的工程实现。

当 worker 三条腿都走不通(本地空、全局空、偷不到),它就 park。原样代码(tokio/src/runtime/scheduler/multi_thread/worker.rs:707,其中的 park_timeout 在 tokio/src/runtime/scheduler/multi_thread/worker.rs:737):

// 来源:tokio/src/runtime/scheduler/multi_thread/worker.rs:707
fn park(&self, mut core: Box<Core>) -> Box<Core> {
    if let Some(f) = &self.worker.handle.shared.config.before_park {
        f();
    }

    if core.transition_to_parked(&self.worker) {
        while !core.is_shutdown && !core.is_traced {
            core.stats.about_to_park();
            core.stats.submit(
                &self.worker.handle.shared.worker_metrics[self.worker.index]
            );
            core = self.park_timeout(core, None);
            core.stats.unparked();
            core.maintenance(&self.worker);
            if core.transition_from_parked(&self.worker) {
                break;
            }
        }
    }

    if let Some(f) = &self.worker.handle.shared.config.after_unpark {
        f();
    }
    core
}

四步:

  1. before_park 回调(第 4 章说的 Builder 字段)
  2. transition_to_parked:尝试把状态从 "Searching" 转到 "Parked"——这一步可能失败(有新任务进来),失败就直接回主循环
  3. 循环 park/unpark(transition_from_parked 在 tokio/src/runtime/scheduler/multi_thread/worker.rs:939):
    • park_timeout(None) 阻塞等待 unpark 信号(None = 无超时)
    • 醒来 core.maintenance 做一轮杂事:提交 stats、检查 shutdown / trace 标志
    • transition_from_parked 判断"真的该起床了吗"——比如spurious wake(虚假唤醒)就继续睡
  4. after_unpark 回调

transition_to_parked 的"最后检查"

这个函数(tokio/src/runtime/scheduler/multi_thread/worker.rs:912)不仅仅是"标记成 parked",它分两道关:

  1. 进门先看自己:第一句 if self.has_tasks() || self.is_traced { return false; }——has_tasks() 只看 lifo_slot 和本地队列这两样本地的东西。有活就返回 false,放弃 park、回主循环
  2. 最后一个搜索者负责再扫一遍全场:idle.transition_worker_to_parked 在 synced 这把 mutex 里把自己登记进 sleepers 并递减计数,返回值告诉调用者"你是不是最后一个退出 searching 的人"。只有是,才调 notify_if_work_pending(调用点 tokio/src/runtime/scheduler/multi_thread/worker.rs:932,函数体 tokio/src/runtime/scheduler/multi_thread/worker.rs:1166)去扫所有 worker 的 steal 句柄和 inject 全局队列

注意第 2 道关的效果:它不会让当前这个 worker 掉头回主循环——transition_to_parked 走到这里一律返回 true,该睡还是睡;它做的是唤醒(可能是另一个)parked worker 去处理刚冒出来的活。这是避免 park-wakeup 竞态的关键:

  • 如果不做这次最后检查,可能发生:"最后一个 searching worker 决定 park → 一个任务在它扫完之后、睡下之前被 push 进来 → 所有 worker 都睡着,没人来拿"
  • 有了它,"任务已入队但通知没人收到"这个窗口被最后一个退出搜索的 worker 兜住了

这一段状态机是整个 scheduler 最容易出 race 的地方,也是 Loom(Tokio 的并发测试工具)反复穷举的重点。

Loom 是什么 —— 简短介绍

Loom 是 Tokio 团队做的Rust 并发正确性测试框架。它会在给定的抢占次数上限内穷举可能的线程交错,让并发 bug 很难逃过测试。

具体做法:

  • 把 std::sync::atomic / std::thread 等标准并发原语替换成 Loom 自己的等价物
  • 运行测试时,Loom 模拟每种可能的 memory ordering 和线程调度顺序
  • 同一个测试会在 Loom 下按不同交错反复执行很多遍

Tokio 的 scheduler、queue、state 这些核心模块都有 cfg(loom) 下的 Loom 专用测试版本。CI 里推到 master 和 tokio-*.x 维护分支的每个提交都会跑 Loom,动到相关模块的 PR 则靠打 R-loom-* 标签触发(.github/workflows/loom.yml)——这是 Tokio 稳定性的重要保证。

你看到 queue.rs 里 LOCAL_QUEUE_CAPACITY = 4 在 cfg(loom) 下的定义(tokio/src/runtime/scheduler/multi_thread/queue.rs:69)就是为了 Loom 测试——把容量缩到 4 让 Loom 更快穷举所有状态。生产是 256,测试是 4,同一份代码两种行为。

Loom 的详细讲解超出本章范围,但你应该知道它存在——这类"穷举模型检查"工具在系统编程里是你信任复杂并发代码最可靠的依据之一。

Park 如何跨线程唤醒

multi_thread 的 park 不是 std::thread::park,而是 tokio/src/runtime/scheduler/multi_thread/park.rs 里自己实现的 Parker / Unparker。它的 Inner(tokio/src/runtime/scheduler/multi_thread/park.rs:24)有四个字段:

// 来源:tokio/src/runtime/scheduler/multi_thread/park.rs:24 (tokio-1.40.0)
struct Inner {
    state: AtomicUsize,          // EMPTY / PARKED_CONDVAR / PARKED_DRIVER / NOTIFIED
    mutex: Mutex<()>,            // 保护 condvar
    condvar: Condvar,
    shared: Arc<Shared>,         // Shared { driver: TryLock<Driver> }
}

关键在这个 TryLock<Driver>:全 runtime 只有一份 I/O / 时间 driver。要休眠的 worker 先 try_lock 抢 driver——

  • 抢到的那个进入 PARKED_DRIVER(常量在 tokio/src/runtime/scheduler/multi_thread/park.rs:40),直接去 epoll_wait(顺带替所有人拉 I/O 事件)
  • 没抢到的进入 PARKED_CONDVAR(tokio/src/runtime/scheduler/multi_thread/park.rs:39),在 condvar 上等

state 那个 AtomicUsize 起的正是"unpark 幂等"的作用:unpark 先把 state 换成 NOTIFIED(tokio/src/runtime/scheduler/multi_thread/park.rs:41),如果换之前是 EMPTY(tokio/src/runtime/scheduler/multi_thread/park.rs:38,还没睡)就直接返回——信号被记下来了,下次 park 会立刻返回。所以多次 unpark 只等于一次,和第 3 章讲的 wake 幂等正好对应。


5.8 搜索者计数:Tokio 限流机制的又一例

前面提到 transition_to_searching 会限制同时 searching 的 worker 数——这是通过 Shared 里一个 atomic 计数器实现的。具体机制:

  • Shared.idle 内部是一个 AtomicUsize,低 16 位存 num_searching、高位存 num_unparked(SEARCH_MASK / UNPARK_SHIFT,tokio/src/runtime/scheduler/multi_thread/idle.rs:26;取位在 tokio/src/runtime/scheduler/multi_thread/idle.rs:202 / tokio/src/runtime/scheduler/multi_thread/idle.rs:207)
  • 任何 worker 进入 searching 前先读一眼:if 2 * state.num_searching() >= self.num_workers { return false; }(tokio/src/runtime/scheduler/multi_thread/idle.rs:106)——即"searching 的 worker 数不得超过一半",通过了才 inc_num_searching(tokio/src/runtime/scheduler/multi_thread/idle.rs:113)
  • 被拒绝就直接返回 None,worker 走向 park。注意源码注释坦白说了:这个"读—改"不是一个原子步,所以偶尔会放进超过 50% 的搜索者,这没关系,限流只是优化不是不变式
  • worker 偷到任务(或放弃搜索去 park)时,num_searching -= 1
  • 偷到任务后额外做一件事:如果 num_searching 变成 0 了,唤醒另一个 worker 继续 searching——避免"最后一个 searching worker 拿到任务,整个系统进入误以为都空闲的 idle 状态"

这一套协议完整的原子操作序列只有几行代码,但它保证:

  • 系统永远有 searching worker(只要有任务流动)——避免"任务堆积但 worker 全 park"
  • searching 数量有上限——避免所有 worker 都在偷空队列,拖累 cache coherence
  • 计数的增减都是原子操作——进入 searching 的"先读后加"虽不是一步完成、偶尔会多放进几个搜索者,但那只是性能上的小偏差,不会让"任务已入队却没人醒着"这种正确性问题发生(兜底靠的是 5.7 节"最后一个搜索者"的那次检查)

这种"搜索者计数 + 自唤醒协议"是工作窃取调度器的常见设计。Go runtime 调度器里的 nmspinning(处于自旋找活状态的 M 的数量)也是类似的计数加门槛。

Tokio 的实现在 tokio/src/runtime/scheduler/multi_thread/idle.rs(1.40.0 下 240 行)——这个文件专门负责这套协议,代码精细到每一个 atomic ordering 都有特定理由:比如 worker_to_notify(tokio/src/runtime/scheduler/multi_thread/idle.rs:51)里那次 load 特意用了 SeqCst 而不是 Acquire,注释(tokio/src/runtime/scheduler/multi_thread/idle.rs:57)写明是为了和"某个 worker 把 num_searching 减到 0"的那次 fetch_sub(1) 配对。读源码的乐趣之一就是,这种看似琐碎的协议居然能让一个庞大的并发系统稳如磐石。


5.9 一次典型调度事件的完整路径

本节是整章的知识总结与应用——把前面所有独立讲解的概念(Core / Shared / 主循环 / work-stealing / LIFO slot / park-unpark)串成一条可追踪的完整事件链。请把这条路径在脑子里走一遍、最好画到纸上——它就是 Tokio 调度器的"心跳"。读完你对 Tokio 任何一次 spawn / wake / poll 背后发生了什么会有完整心智模型。

把前面所有东西拼起来,看一次典型调度在 Tokio 里的走位:

场景:worker 0 正 poll 一个 async fn,它内部 tokio::spawn 了一个子任务。

  1. spawn 发生:

    • 新 Task 创建,经 schedule_local 直接占住 worker 0 的 lifo_slot
    • 原 lifo_slot 里的 Task(如果有)被挤到本地队列尾部
  2. 当前 async fn 继续执行:

    • 遇到 .await → 如果子任务还没完成,当前 Task 返回 Pending
    • 父 Task 这次 poll 结束
  3. Worker 0 取下一个 Task:

    • 不必回到主循环:run_task 里的 LIFO 循环发现 slot 非空,直接 core.lifo_slot.take()(tokio/src/runtime/scheduler/multi_thread/worker.rs:617)
    • 即使回到主循环,next_task 的分支 B 调的 next_local_task 也是 self.lifo_slot.take().or_else(|| self.run_queue.pop())——先掏 LIFO slot,掏不到才 pop 本地队列
    • 于是拿到的正是刚 spawn 的子任务,开始 poll
  4. 子任务执行完返回 Ready:

    • handle.await 的 handle 被唤醒(通过 JoinHandle 内部的 oneshot-like 机制)
    • 父 Task 被 wake → 唤醒发生在 worker 0 上,所以它同样经 schedule_local 进 worker 0 的 LIFO slot(若本轮 LIFO 已被关掉,则进本地队列尾部)
    • Worker 继续循环

这整条路径的 hot 部分几乎都是当前 worker 独占的访问——lifo_slot 是纯私有字段,run_queue 的 pop 也只在本地 CAS 一次。这就是 Tokio 高性能的秘密:把绝大多数调度事件留在本核上。

跨 worker 的场景什么时候发生

那哪些场景会触发跨核?主要四类:

  1. 从非 worker 线程 spawn:在 std thread 里用事先 clone 好的 Handle 调 handle.spawn(fut) —— Task 进全局 inject 队列,worker 从那里取(跨核)
  2. I/O Driver 由某个 worker 顺带驱动:I/O Driver 可能正由 worker-0 线程在 park 时跑 epoll,被它唤醒的 Task 进的是 worker-0 自己的队列,要靠别的 worker 偷走或从全局队列拉取才能分摊出去(跨核)
  3. Worker 工作窃取:每次偷都是跨核
  4. channel 跨 worker 发消息:sender 在 worker-1,receiver 的 Task 在 worker-3,wake 时跨核

这四类是 Tokio 无法避免的跨核场景。Tokio 的设计是这些场景里原子操作和 cache miss 最小化——偷任务偷一把、wake 幂等、从全局队列批量拉取。把不可避免的跨核成本压到最低,而不是假装它不存在,这是工程现实主义。


5.10 和其他语言调度器的对比

把 Tokio 调度器放在 Go runtime、Java ForkJoinPool、Erlang BEAM 并列看非常有意思——各种语言的并发运行时都在解决同一个问题、但设计选择千差万别。Go 选择 M:N 抢占式调度、Erlang 选择 reduction count 抢占、Java 选择 cooperative + work-stealing、Tokio 选择 cooperative + work-stealing + LIFO slot。这些选择各有代价和收益——对比它们的取舍能让你理解"Tokio 为什么是今天这样"不是凭空选的、而是在特定约束(Rust 无 GC、异步状态机语义)下做出的取舍。

维度 Tokio multi_thread Go GMP Erlang BEAM Node.js libuv
调度单位 Task(async state machine) Goroutine(独立栈) Process(ErlVM process) Callback
每 worker 本地队列 256 容量环形 256 容量数组 每 scheduler 独立 无
工作窃取 ✅ 随机起点 ✅ 随机起点 ✅ 主动调度 ❌(单线程 event loop)
LIFO 优化 ✅ 有 lifo_slot ✅ 有 runnext ❌ N/A
全局队列 ✅ inject ✅ global runq ✅ migration N/A
公平性 global_queue_interval 类似机制 reduction counting N/A

结论:Tokio 的多线程调度器和 Go runtime 的 GMP 非常接近——两者都是"每 P 一个本地队列 + 全局队列 + 工作窃取 + LIFO 优化"。主要差异是:

  • Go 的 Goroutine 有独立栈(2 KB 起),Tokio 的 Task 是 state machine(常常几十字节)
  • Go 的调度器更主动(sysmon 按时间片发起抢占,Go 1.14 起还支持基于信号的异步抢占),Tokio 依赖 .await 自然让出
  • Go 有 GC,Tokio 无

Tokio 借鉴了 Go 的很多调度经验,而且是写在源码里的:tokio/src/runtime/builder.rs:243-244 里 event_interval 默认值 61 旁边就跟着一句注释——"The number 61 is fairly arbitrary. I believe this value was copied from golang."

和 Go GMP 的具体差异,以及为什么 Tokio 有独特取舍

Go GMP 里 M 是 OS 线程、P 是逻辑处理器、G 是 goroutine。Tokio 的对应:Worker = M + P 合一(一个 OS 线程绑一个 Core),Task = G。但几个关键差异值得单独说:

差一:Go 的 G 有独立栈,可以在执行中途被抢占(编译器在函数序言插入抢占检查,Go 1.14 起还能靠信号在几乎任意指令处异步抢占)。Tokio 的 Task 没有独立栈,让出只在显式 .await 处。意味着:一个 Tokio Task 如果写成纯 CPU 循环(没有 await),它会占住 worker 直到跑完。这就是第 16 章要讲"CPU 密集代码必须 spawn_blocking"的根源。

差二:Go 的调度器有"sysmon" 后台线程定期检查"是否有 G 占了 P 太久",会抢占。Tokio 没有这个——完全靠协作。这让 Tokio 更轻量(省一个监控线程),但要求用户代码遵守协作契约。

差三:Go 的 M 可以动态创建(sysmon 发现 P 都阻塞就 new M),数量可达几千。Tokio 的 worker 数量固定(build 时决定),Core 的个数不会动态增加(block_in_place 会另起一个线程接手 Core,但 Core 总数不变)。这让 Tokio 的资源占用可预测,但也意味着"所有 worker 都阻塞 = 系统停滞"。

这些差异不是"Tokio 不如 Go",是"两种不同的取舍"。Go 做的是"对用户尽可能透明,牺牲一点运行时复杂度";Tokio 做的是"保持运行时极简,让用户按契约协作"。Rust 生态的典型美学:显式 > 隐式。


5.11 和这个系列的其他专栏的关联

本章的"队列 + 批量 flush"骨架,和 《Vue 3 设计与实现》专栏第 12 章(生命周期与调度) 里 Vue 的 queueJob + flushJobs 调度机制有同型结构——都是"push 进队列 + 批量 flush + 去重"。但 Vue 在浏览器单线程里不需要 work-stealing,Tokio 在多线程服务端必须。同一问题(异步任务调度)在不同运行时环境下的不同解是非常有启发性的对比。

另一条线是"被调度的东西是什么":《Rust 编译器与运行时揭秘》专栏第 9 章(async 状态机)和第 10 章(Pin / Waker / Future) 讲的是 async fn 怎么被编译成一个状态机、以及它凭什么可以被搬来搬去——本章从 Notified 开始往下讲的所有队列操作,搬运的正是那个状态机。


5.12 对比 Cilk:学术世界的 work-stealing 起源

最后给调度的工作窃取算法一个历史维度。Tokio 的工作窃取可以追溯到 1994 年 MIT 的 Cilk 项目——该项目第一次系统地研究并论证了"分布式工作窃取调度器"的性能上界。

Cilk 的核心定理:N 核上的工作窃取调度器,对于一个总工作量为 W、关键路径长度为 S 的并行程序,期望运行时间是 W/N + O(S)。这个定理表明:

  • 工作窃取接近理论最优并行加速
  • 关键路径 S 是根本瓶颈——序列化不可避免的部分
  • 这个上界是在"随机挑选受害者"的前提下证明的——随机化是定理成立的条件之一,不只是工程上的小技巧

LIFO slot、随机起点、批量偷窃——这些不是凭空发明的,是数十年学术研究的工程化落地。

如果你想深入学术侧,读 Cilk 的论文 "Cilk: An Efficient Multithreaded Runtime System"(Blumofe et al., 1995)。读完你会发现 Tokio 2019 年的调度器改造在核心思路上和 Cilk 一脉相承——二十多年前的成熟技术在 Rust 生态落了地。


5.13 本章小结

读完这章你会发现:Tokio 的调度器没有奇迹,只有对细节的狂热打磨。每一个字段、每一个位、每一个 ordering 都有解释。你现在有了看懂任何调度器代码的基础——Go、Erlang、rayon、Java ForkJoinPool——你都能用同一套词汇(work-stealing、LIFO、fairness、searching、park/unpark)解码它们。

带走三件事:

  1. Tokio multi_thread scheduler 由三个 struct 支撑:Core(每 worker 独占,含 lifo_slot + 本地队列)、Shared(跨 worker 共享,含 inject 全局队列)、Worker(容器)
  2. Worker 主循环的三条腿:next_task(本地 + 偶尔全局)→ steal_work(随机起点扫其他 worker)→ park(真的没活了才休眠)
  3. 本地队列用一个 AtomicUnsignedLong 打包两个头指针(real head + steal head),使 owner 的 pop 和 thief 的 steal 可以无锁并发,这是 Tokio 性能的核心秘密之一

5.14 Tokio 调度器的演化史:从 0.1 到 1.40

Tokio 的多线程调度器经历过几次大重构,每次都有明确的性能或正确性目标。简要时间线:

2017-2019:Tokio 0.1 —— 基于 futures 0.1

早期调度器建立在 futures 0.1 之上,住在一个单独的 tokio-threadpool crate 里。它已经是工作窃取的——这个 crate 的模块文档开头第一句就写着 "A work-stealing based thread pool for executing futures"(tokio-0.1.22 标签下),每个 worker 一个 crossbeam_deque 双端队列,没活时随机扫其他 worker 的 deque 去偷。(模块文档还说每个 worker 另有一条 mpsc channel 收外部提交,那是更早版本的实现;到 tokio-0.1.22 标签,外部提交已经改走下面这个全局队列。)

全局队列也已经有了:同一 crate 的 Pool 结构体里有一个 queue: Arc<Injector<Arc<Task>>> 字段,注释写着 "The global MPMC queue of tasks"。和今天真正的差别在队列的形态和 slot:没有 LIFO slot,本地队列是 crossbeam 的可增长 deque 而不是自己实现的定容环形缓冲,Task 也还是 Arc<Task> 而不是第 6 章要讲的那种单次分配的 Cell。下一轮重构换掉的正是这几样。

2019:调度器大改造 —— 重写队列,加上 LIFO slot

这一轮改造的记录就是那篇经常被引用的博客《Making the Tokio scheduler 10x faster》:工作窃取和全局队列都保留,但每 worker 的本地队列换成 Tokio 自己实现的定容环形缓冲(本章 5.5 节那套队列的前身,容量已经是 256;steal / real 双头打包是 2020 年才加的,见下一小节),并补上 LIFO slot(PR #1657,2019-10-19,随 Tokio 0.2.0 发布,当时是一个名为 next 的字段),今天 tokio/src/runtime/scheduler/multi_thread/worker.rs 的骨架就是这一轮定下来的。想知道每个设计点当初解决的是什么问题,读这篇比读任何二手材料都值。

2020:scheduler v2.5 —— 本地队列定型

PR #2273(2020 年 3 月)把原先分散在 queue/ 目录下的几个文件合并成单个 queue.rs;同月的 #2315 把窃取限制为"同一时刻只有一个窃取者",引入了 5.5 节那对打包在一起的 steal / real 双头(索引后来在 #5042、Tokio 1.22.0 里加宽为 u32 打包进 u64,以缓解 ABA)。LOCAL_QUEUE_CAPACITY = 256 这个常量从 2019 年那轮改造起就没再变过。同期还有 #2349("cap fifo scheduler slot to avoid starvation"——用 coop 预算限制连续从 LIFO slot 取任务,防乒乓饥饿;那个按次数计的 MAX_LIFO_POLLS_PER_TICK = 3 是后来 #5712 才加的第二道闸)等一串修补。

2022:disable_lifo_slot —— 承认 LIFO slot 有代价

Tokio 1.21.0 在 tokio_unstable 下加入 Builder::disable_lifo_slot()(PR #4936)。原因写在 issue #4941:LIFO slot 里的任务不可被窃取,任务 poll 时间长时反而拖低总吞吐。文档明确说这是"a workaround",等 slot 变得可窃取后会重新考虑。

2024:block_in_place 不再"扣押"LIFO slot(1.38.0)

PR #6596:block_in_place 把 Core 交给新线程前,先把 lifo_slot 里的任务倒进本地队列,让它重新变得可窃取。

MultiThreadAlt —— 仍在实验中的备选架构

Builder::new_multi_thread_alt()(tokio_unstable 门禁)是一个另一种架构的多线程调度器原型,代码在 runtime/scheduler/multi_thread_alt/。和现行实现比,主要差异在"Core 归谁":

  • Core 会在线程间迁移:Worker 只放栈上不迁移,Core(#[repr(align(128))] 堆分配,源码注释直写 "Data is heap-allocated and migrates threads.",tokio/src/runtime/scheduler/multi_thread_alt/worker.rs:115)通过 Synced::assigned_cores(tokio/src/runtime/scheduler/multi_thread_alt/worker.rs:185)在 mutex 保护下派发给被唤醒的 worker
  • 各 worker 的 Condvar 集中放在 Shared::condvars: Vec<Condvar> 里(tokio/src/runtime/scheduler/multi_thread_alt/worker.rs:161),每个 worker 等自己那一个;现行实现则是每个 worker 一个 Parker,Condvar 在各自的 Parker 里
  • driver 由 AtomicCell<Driver> 共享(tokio/src/runtime/scheduler/multi_thread_alt/worker.rs:157),轮流由某个 worker 驱动
  • 本地队列仍然是同一套 atomic 位打包环形队列,全局队列仍然是单一 inject

官方文档写得很直白:"currently does not scale as well to 16+ processors"、"not considered production ready"(tokio/src/runtime/builder.rs:253)。

这段历史给你的启示

  1. 调度器没有"一次写对"——行业头部项目也在持续迭代 5-10 年
  2. 每次改动都要有具体的性能数据支撑——不要靠直觉优化
  3. 抽象的正确性测试(Loom)是头等公民——Tokio 调度器的核心模块都有 Loom 测试穷举线程交错,这是它稳定性的基石

你读本章时看到的"看似理所当然"的设计,很多是在这些年的真实负载、基准测试和 bug 报告里反复打磨出来的。不要觉得这些设计理所当然。


5.15 一些容易被忽略的细节

在告别本章之前,把几个容易被读者忽略但值得记住的小细节串一下:

细节一:Worker 启动不是并行的

worker 线程不是由 runtime 直接 thread::spawn 出来的,而是 Launch::launch(tokio/src/runtime/scheduler/multi_thread/worker.rs:464)里一个 for 循环逐个 runtime::spawn_blocking(move || run(worker))(tokio/src/runtime/scheduler/multi_thread/worker.rs:466) —— 借用 blocking 线程池按索引顺序提交,谁先真正跑起来由线程池决定。这意味着在 runtime 刚启动的前几毫秒,一些 worker 可能还没准备好、它们的 Core 还没被 take。如果这时有 Task 被 spawn,可能会先进全局队列,等 worker 就绪后被偷/拉走。这是启动窗口的微妙行为,极少影响生产。

细节二:每个 worker 都持有一份 Arc<Handle>

每个 worker 持有 Arc<Handle> 的一份 clone(共享同一个引用计数)——意味着要等所有 worker 都释放这个 Arc,Handle 才会真正析构。而 worker 线程本身跑在 blocking 线程池里(见细节一),runtime 关停时由线程池等它们退出。

细节三:Remote 结构体

Shared.remotes: Box<[Remote]> —— 每个 worker 对应一个 Remote。Remote 里主要是该 worker 队列的 Steal<T> 句柄(让别的 worker 偷)和一个 unpark 句柄(让别的地方唤醒这个 worker)。Remote 是"其他人对这个 worker 的访问入口"——每 worker 本地的 Core 私有,Remote 公开。

细节四:调度决策是完全本地化的

worker A 决定下一步做什么,不会询问 worker B 的意见。没有中央协调。全局队列和 idle 状态虽然仍在 synced 这把 mutex 下,但只在本地队列取空、park / unpark 这些非热路径上才碰它——中央协调是调度器的最大扩展性杀手。

把这四个细节合起来,你就理解了 Tokio scheduler 的设计精髓:无中央、最小共享、最大并发。


5.16 实战:如何从 tokio-console 看调度问题

光讲理论没用,来看真实场景怎么调试调度问题。

工具:tokio-console —— Tokio 官方的可视化调试工具。用法是给应用加上 console-subscriber 依赖并在启动时 console_subscriber::init(),编译时带上 RUSTFLAGS="--cfg tokio_unstable"(tokio-console 读取的是 tokio 的 tracing 埋点,这些埋点在这个门禁下;它并不读 RuntimeMetrics),再用 tokio-console 命令连接。

场景 1:某 worker 长时间繁忙,其他 worker 空 症状:用 RuntimeMetrics::worker_total_busy_duration 按 worker 采样(1.40 下这组 worker 级 metrics 同样要 tokio_unstable),某个 worker 的忙碌时间占比接近 100%,其他接近 0。

  • 可能原因:工作没有拆成独立任务(都在同一个任务里用 join! / select! 并发,而一个任务同一时刻只能在一个 worker 上跑)
  • 或:某个 Task 持有了只能在本 worker 跑的资源(罕见,但存在)
  • 排查:看这个 busy worker 上在跑什么 Task

场景 2:所有 worker 都 busy 但吞吐上不去 症状:各 worker 的 worker_total_busy_duration 占比都接近 100%、worker_poll_count 很高、但用户吞吐一般。

  • 可能原因:Task 里有大量同步阻塞(文件 I/O 没走 spawn_blocking、CPU 密集计算),worker 在跑不能让出的代码
  • 排查:flamegraph + tokio-console 的 poll_duration 分布——如果某个 Task 的单次 poll 超过几百微秒,就是阻塞了
  • 修法:第 16 章(spawn_blocking 与 block_in_place)

场景 3:某 Task 永久 pending 症状:tokio-console 显示某 Task 有 idle_duration 但 poll_count 很低。

  • 就是第 3 章的 BrokenCounter / stale Waker 问题
  • 排查:查 Task 注册 Waker 的位置,看有没有人 wake 它

这三类症状对应调度器的三种常见病。掌握 tokio-console 的读图能力是生产调优的入门技能——第 17 章会专门讲这个工具的使用细节。


延伸阅读