协程调度器
主线位置:主线 1 里「谁在跑」的那一层。
Execution::schedule决定下一个该跑哪个线程,Scheduler负责真的把它跑起来:每个模型线程是一个有栈协程,切换线程就是从一个协程 yield 回调度循环、再 resume 另一个。
sequenceDiagram participant R as Scheduler::run(OS 线程) participant T0 as 0 号协程(模型闭包) participant T1 as 1 号协程 R->>T0: tick:STATE.set(execution),resume T0->>T0: thread::spawn:new_thread() 登记,闭包放进 queued_spawn T0->>T0: load:branch → schedule() 选中 1 号 → switch() T0-->>R: yield_with(()),tick 返回 queued_spawn R->>T1: spawn_thread 建协程、resume 到用户代码之前 R->>T1: tick:resume,从闭包开头跑 T1-->>R: 某次 branch 选中 0 号 → yield R->>T0: tick:resume,从上次 switch 处继续
rt/scheduler.rs 只有 161 行。它不做任何决策:选线程是 Execution::schedule 的事,Scheduler 只回答「怎样在一个 OS 线程上暂停一段 Rust 代码、过一会儿再接着跑」。
线程就是协程
type Thread = Generator<'static, Option<Box<dyn FnOnce()>>, ()>;
scheduler.rs:14。Generator 来自 generator crate(loom 要求 0.8.1 以上,本卷实验锁的是 0.8.11):一个自带独立栈的协程,resume 进去,里面调 yield_ / yield_with 就回到 resume 的调用点,下次 resume 从 yield 处接着跑。参数类型 Option<Box<dyn FnOnce()>> 是「要跑的闭包」,返回类型 ()。
建协程的 spawn_thread:
fn spawn_thread(f: Box<dyn FnOnce()>, stack_size: Option<usize>) -> Thread {
let body = move || {
loop {
let f: Option<Option<Box<dyn FnOnce()>>> = generator::yield_(());
if let Some(f) = f {
generator::yield_with(());
f.unwrap()();
} else {
break;
}
}
generator::done!();
};
let mut g = match stack_size {
Some(stack_size) => Gn::new_opt(stack_size, body),
None => Gn::new(body),
};
g.resume();
g.set_para(Some(f));
g
}
一个协程从建好到结束要被 resume 四次:
| 第几次 resume | 谁调 | 协程里发生什么 |
|---|---|---|
| 1 | spawn_thread 自己 | 跑到第一个 yield_(()) 停下;随后 set_para(Some(f)) 把闭包放进去 |
| 2 | run(在 tick 之外) | yield_ 返回 Some(Some(f)),立刻 yield_with(()) 再停一次 |
| 3 起 | tick | 真正调用 f(),直到 f 里第一次 Scheduler::switch;之后每次被选中都从 switch 处接着跑;f 返回后回到循环顶部的 yield_ 停下 |
| 最后 | run 看到所有线程都结束时 | yield_ 没有参数,返回 None,break,done!() |
第 2 次那个「拿到闭包却先停一下」的 yield_with,从 run 的写法看是为了保证用户代码只在 tick 里面跑:run 对新协程的第一次 resume 发生在 tick 之外(scheduler.rs:82-83、104-105),那时 STATE 没有设置,如果直接跑 f,f 里第一个 loom 操作就会 panic。
每一轮都重建协程
run:
pub(crate) fn run<F>(&mut self, execution: &mut Execution, f: F)
where
F: FnOnce() + Send + 'static,
{
let mut threads = Vec::new();
threads.push(spawn_thread(Box::new(f), None));
threads[0].resume();
loop {
if execution.threads.is_complete() {
for thread in &mut threads {
thread.resume();
assert!(thread.is_done());
}
return;
}
let active = execution.threads.active_id();
let mut queued_spawn = Self::tick(&mut threads[active.as_usize()], execution);
while let Some(th) = queued_spawn.pop_front() {
assert!(threads.len() < self.max_threads);
let thread_id = threads.len();
let QueuedSpawn { f, stack_size } = th;
threads.push(spawn_thread(f, stack_size));
threads[thread_id].resume();
}
}
}
threads是run的局部变量,每一轮都新建,一轮结束全部 drop。Scheduler结构体本身只有一个max_threads字段(scheduler.rs:10-12)。所以一个跑 10 万个交错的模型,要建、拆 10 万次(乘以线程数)协程栈。- 循环里每次只做一件事:
execution.threads.active_id()是谁,就tick谁。「谁是 active」由上一次schedule()写进execution.threads。 - 协程在
Vec里的下标就是 loom 的线程号:0 号是模型闭包,之后按 spawn 顺序 1、2、3、4。execution.threads里的线程号也是这样分配的,两边始终对齐。
STATE:只在 tick 期间存在
调度器和运行时之间只有一条通道,一个 scoped-tls 的线程局部变量(scheduler.rs:16-28):
scoped_thread_local! {
static STATE: RefCell<State<'_>>
}
struct State<'a> {
execution: &'a mut Execution,
queued_spawn: &'a mut VecDeque<QueuedSpawn>,
}
它只在 tick 里被设置:
fn tick(thread: &mut Thread, execution: &mut Execution) -> VecDeque<QueuedSpawn> {
let mut queued_spawn = VecDeque::new();
let state = RefCell::new(State {
execution,
queued_spawn: &mut queued_spawn,
});
STATE.set(unsafe { transmute_lt(&state) }, || {
thread.resume();
});
queued_spawn
}
STATE.set 要求 'static,而 State 里是借来的 &mut Execution,所以用 transmute_lt 把生命周期抹掉(scheduler.rs:159-161)。成立的前提是:STATE.set 的作用域在 tick 返回前结束,协程里对 STATE 的所有访问都发生在这次 resume 期间。
loom 里每个对外类型的每次操作,最后都走 rt::execution(|execution| ...) → Scheduler::with_execution → with_state:
fn with_state<F, R>(f: F) -> R
where
F: FnOnce(&mut State<'_>) -> R,
{
if !STATE.is_set() {
panic!("cannot access Loom execution state from outside a Loom model. \
are you accessing a Loom synchronization primitive from outside a Loom test (a call to `model` or `check`)?")
}
STATE.with(|state| f(&mut state.borrow_mut()))
}
这就是「在 model 外面用 loom 类型」的报错来源。实测在普通 #[test] 里写一句 loom::sync::atomic::AtomicUsize::new(0):
thread 'outside_model' panicked at …/loom-0.7.2/src/rt/scheduler.rs:128:13:
cannot access Loom execution state from outside a Loom model. are you accessing a Loom synchronization primitive from outside a Loom test (a call to `model` or `check`)?
连构造都不行,因为 AtomicUsize::new 要往 execution.objects 里插一项(Execution 与对象仓库)。所以 loom 的原子类型没有 const fn new,不能放进 static;被测库的 static 原子、const fn new 都要给 loom 另写一份,见 cfg(loom) 切换层。
switch:为什么包一层 async
线程切换就是当前协程 yield 回 tick。switch 却没有直接调 generator::yield_with(()):
pub(crate) fn switch() {
// …noop waker 的定义省略…
// Wrapping with an async block deals with the thread-local context
// `std` uses to manage async blocks
let mut switch = async { generator::yield_with(()) };
let switch = unsafe { Pin::new_unchecked(&mut switch) };
let raw_waker = RawWaker::new(
ptr::null(),
&RawWakerVTable::new(noop_clone, noop, noop, noop),
);
let waker = unsafe { Waker::from_raw(raw_waker) };
let mut cx = Context::from_waker(&waker);
assert!(switch.poll(&mut cx).is_ready());
}
它把 yield 包进一个 async 块,造一个什么都不做的 waker,手动 poll 一次(这个 async 块里没有 .await,一次 poll 必然 Ready)。注释说这是为了处理「std 管理 async 块用的线程局部上下文」。引入它的提交是 546c77d「fixes Loom when using async/await (#102)」(2019-11-16),那时 async/await 刚稳定。推测:当时 std 把「当前正在 poll 的任务上下文」放在一个线程局部变量里;几个协程共用同一个 OS 线程,模型代码在一个 async 块中间被切走、另一个协程又进出自己的 async 块,这份线程局部状态就会被改掉。把 yield 放进一个新的 async 块、自己 poll 它,切换点前后的线程局部上下文由这次 poll 自己设置和恢复。这个解释来自注释的字面意思和提交时间,本卷没有在当年的工具链上复现。
spawn 不是调度点
模型里 loom::thread::spawn 最终到 rt::spawn:
pub(crate) fn spawn<F>(stack_size: Option<usize>, f: F) -> crate::rt::thread::Id
where
F: FnOnce() + 'static,
{
let id = execution(|execution| execution.new_thread());
trace!(thread = ?id, "spawn");
Scheduler::spawn(
stack_size,
Box::new(move || {
f();
thread_done();
}),
);
id
}
两件事:new_thread 在 execution.threads 里登记一个可运行的新线程(继承父线程的向量时钟,见 Execution 与对象仓库);Scheduler::spawn 只是把闭包推进 queued_spawn(scheduler.rs:73-75)。中间没有 branch,不调 schedule,父线程接着往下跑。新协程要等这次 tick 返回后才由 run 建出来,而它第一次真正执行,要等某次 schedule() 选中它。
这有两个后果:
- 「spawn 完立刻被抢占」这件事,在 loom 里表现为父线程下一个 loom 操作处的调度分支选了子线程,而不是 spawn 本身。一次 model 的一生 的第 2 个交错就是这样:父线程准备 load 时,调度选了刚 spawn 的 1 号线程。
- 用
stop_exploring()/explore()把一串 spawn 包起来没有作用,因为这一段里没有分支可以不探索。实测见 抢占上限与剪枝 的 E17:包与不包都是 116 319 个交错。
thread::spawn 的 mock 还有其他和 std 不同的地方(没有 Send 约束、join 恒 Ok),在 线程、线程局部与 lazy_static。
栈大小与 #![allow(deprecated)]
文件第一行是 #![allow(deprecated)]。被压掉的是 generator 自己的弃用标记:Gn::new、自由函数 generator::yield_ 和 yield_with 在 generator 0.6.18 起标了 #[deprecated(note = "please use scope version instead")](generator 0.8.11 的 gen_impl.rs 第 249 行、yield_.rs 第 64、98 行),新写法是把一个 Scope 参数传进协程体。loom 仍用旧 API;Gn::new_opt 没有被弃用(generator 源码注释:「the may library use this API so we can't deprecated it yet」)。
栈大小有两种来源:
| 来源 | 走哪条 | 实际大小 |
|---|---|---|
thread::spawn、模型闭包本身 | Gn::new → DEFAULT_STACK_SIZE | generator 里定义为 0x8000 / size_of::<usize>() 个字,64 位上是 4 096 个字,即 32 KiB |
thread::Builder::stack_size(n)(0.6.0 的 #311,ce8a232) | Gn::new_opt(n, …) | new_opt 的参数单位也是字(generator stack/mod.rs 的 Stack::new 里 size * size_of::<usize>()),64 位上是 n × 8 字节 |
loom 的 Builder::stack_size 文档写的是「Sets the size of the stack (in bytes)」(thread.rs:204-209),原样把这个数传给 generator,按代码,实际得到的栈是文档所说的 8 倍(64 位)。另外 generator 在 size 为奇数时会对整个栈做占用统计(Stack::new 里 let track = (size & 1) != 0),这是 generator 的行为,loom 没有提及。
默认 32 KiB 的栈比 std 线程的默认栈小得多,模型闭包本身(0 号线程)也只有这么大。模型线程里放大数组或深递归时,这是一个与被测代码无关的限制,只能用 thread::Builder::stack_size 给子线程放大;0 号线程没有入口可调。
演进
| 时间 | 提交 | 变化 |
|---|---|---|
| 2018-12-15 | 2b37a35「Initial commit」 | rt/scheduler/ 下有 fringe、gen、std、stub 四种后端 |
| 2019-06-04 | 1e32734「Rust 2018 (#15)」 | 删掉用真 OS 线程的 std 后端 |
| 2019-07-23 | 399c071「Only use generator for scheduler (#23)」 | 只留 generator 协程 |
| 2019-11-16 | 546c77d「fixes Loom when using async/await (#102)」 | switch 包进 async 块 |
| 2023-06-11 | ce8a232「Support setting model thread stack size (#311)」 | QueuedSpawn.stack_size 与 Gn::new_opt |
小结
- 每个模型线程是一个 generator 有栈协程,协程下标就是线程号;
run每一轮重建全部协程,默认栈 32 KiB。 STATE是 scoped TLS,只在tick的那次resume期间指向当前Execution;model 外用 loom 类型(包括构造)都在scheduler.rs:128panic。- 切换线程 =
switch()里yield_with回tick;外面包一层 async 块是 #102 为 async/await 加的。 spawn只登记线程、排队闭包,不是调度点;子线程第一次运行取决于之后某次schedule()。#![allow(deprecated)]压的是 generator 的Gn::new/yield_/yield_with;Builder::stack_size文档说字节,generator 按字解释。