Rust in Everything
基于 loom 0.7.2

协程调度器

主线位置:主线 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谁调协程里发生什么
1spawn_thread 自己跑到第一个 yield_(()) 停下;随后 set_para(Some(f)) 把闭包放进去
2run(在 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_SIZEgenerator 里定义为 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-152b37a35「Initial commit」rt/scheduler/ 下有 fringe、gen、std、stub 四种后端
2019-06-041e32734「Rust 2018 (#15)」删掉用真 OS 线程的 std 后端
2019-07-23399c071「Only use generator for scheduler (#23)」只留 generator 协程
2019-11-16546c77d「fixes Loom when using async/await (#102)」switch 包进 async 块
2023-06-11ce8a232「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:128 panic。
  • 切换线程 = switch() 里 yield_with 回 tick;外面包一层 async 块是 #102 为 async/await 加的。
  • spawn 只登记线程、排队闭包,不是调度点;子线程第一次运行取决于之后某次 schedule()。
  • #![allow(deprecated)] 压的是 generator 的 Gn::new / yield_ / yield_with;Builder::stack_size 文档说字节,generator 按字解释。

💬 关联讨论

发起讨论
加载中...