Tokio 源码深度解析
第5章 多线程 Scheduler 与工作窃取
本章要点
- 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),不是固定的 31park / 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 件事:
- 从
AtomicCell里取出 Core——如果已经被别人取走(说明另一个线程正在运行这个 worker),直接返回 - 设置 worker 的 thread_id—— 用于 metrics 和 tracing
- enter_runtime:把当前线程 thread-local 设置成"我是 Tokio 的 worker"
- set_scheduler:再设一层 thread-local,标明"当前 scheduler 是 multi_thread"
- 调
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(常态)
- 先调
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 - 本地没了,先看全局队列是不是真的空(
is_empty无锁检查) - 全局有 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);
}
三个技术细节值得驻足:
tail.wrapping_sub(steal):用 wrapping(环绕)减法算队列当前长度。tail和steal都是UnsignedShort(常见平台上是u32),它们可以自然 wrap——环绕减法恰好计算出循环距离unsync_load读 tail:owner 自己就是唯一写 tail 的人,无需 atomic load——省一条屏障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);
}
这段代码的调用序列:
- 调用
tokio::spawn——一个新 Task 被交给当前 worker 调度 handle.await——当前 Task yield(返回 Pending)- 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
}
四步:
before_park回调(第 4 章说的 Builder 字段)transition_to_parked:尝试把状态从 "Searching" 转到 "Parked"——这一步可能失败(有新任务进来),失败就直接回主循环- 循环 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(虚假唤醒)就继续睡
after_unpark回调
transition_to_parked 的"最后检查"
这个函数(tokio/src/runtime/scheduler/multi_thread/worker.rs:912)不仅仅是"标记成 parked",它分两道关:
- 进门先看自己:第一句
if self.has_tasks() || self.is_traced { return false; }——has_tasks()只看lifo_slot和本地队列这两样本地的东西。有活就返回 false,放弃 park、回主循环 - 最后一个搜索者负责再扫一遍全场:
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 了一个子任务。
-
spawn 发生:
- 新 Task 创建,经
schedule_local直接占住 worker 0 的 lifo_slot - 原 lifo_slot 里的 Task(如果有)被挤到本地队列尾部
- 新 Task 创建,经
-
当前 async fn 继续执行:
- 遇到
.await→ 如果子任务还没完成,当前 Task 返回 Pending - 父 Task 这次 poll 结束
- 遇到
-
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
- 不必回到主循环:
-
子任务执行完返回 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 的场景什么时候发生
那哪些场景会触发跨核?主要四类:
- 从非 worker 线程 spawn:在 std thread 里用事先 clone 好的
Handle调handle.spawn(fut)—— Task 进全局 inject 队列,worker 从那里取(跨核) - I/O Driver 由某个 worker 顺带驱动:I/O Driver 可能正由 worker-0 线程在 park 时跑 epoll,被它唤醒的 Task 进的是 worker-0 自己的队列,要靠别的 worker 偷走或从全局队列拉取才能分摊出去(跨核)
- Worker 工作窃取:每次偷都是跨核
- 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)解码它们。
带走三件事:
- Tokio multi_thread scheduler 由三个 struct 支撑:Core(每 worker 独占,含 lifo_slot + 本地队列)、Shared(跨 worker 共享,含 inject 全局队列)、Worker(容器)
- Worker 主循环的三条腿:next_task(本地 + 偶尔全局)→ steal_work(随机起点扫其他 worker)→ park(真的没活了才休眠)
- 本地队列用一个 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)。
这段历史给你的启示
- 调度器没有"一次写对"——行业头部项目也在持续迭代 5-10 年
- 每次改动都要有具体的性能数据支撑——不要靠直觉优化
- 抽象的正确性测试(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 章会专门讲这个工具的使用细节。
延伸阅读
- Tokio 源码:
tokio/src/runtime/scheduler/multi_thread/worker.rs—— Core / Worker / run / next_task / steal_work / park - Tokio 源码:
tokio/src/runtime/scheduler/multi_thread/queue.rs—— 本地队列的双头打包实现 - Chase, D. & Lev, Y. 的论文 "Dynamic Circular Work-Stealing Deque"(2005)—— 工作窃取队列的理论基础
- Tokio blog: "Making the Tokio scheduler 10x faster" —— Carl Lerche 写的调度器改造历史