Rust in Everything
基于 loom 0.7.2

线程、线程局部与 lazy_static

主线位置:主线 1 里「一轮从哪开始、到哪结束」:spawn 把闭包排进 queued_spawn,所有线程都终止才算一轮结束,thread_local! 在线程结束时析构,lazy_static! 在主闭包返回时析构;以及主线 4 里线程 panic、lazy_static 两次初始化这两条与 std 不同的行为。

sequenceDiagram
  participant M as 主线程(0 号协程)
  participant E as Execution
  participant C as 子线程(1 号协程)
  M->>E: thread::spawn:new_thread(继承主线程 causality),排进 queued_spawn
  Note over M,E: spawn 本身不是调度点
  E->>C: tick 返回后创建协程
  C->>C: init_current;result = Some(Ok(f()));notify.notify()
  C->>E: thread_done:析构本线程的 thread_local!,Terminated
  M->>E: join:notify.wait() → 取 result,恒 Ok
  M->>E: 主闭包返回:lazy_statics.drop(),然后 thread_done
  E->>E: 所有线程 Terminated → 本轮结束 → check_for_leaks → step

spawn:没有 Send 约束

thread.rs:121-133:

#[track_caller]
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T,
    F: 'static,
    T: 'static,
{
    spawn_internal(f, None, None, location!())
}

std 的 spawn 要求 F: Send、T: Send;loom 的没有(Builder::spawn 倒是写了 Send,214-221 行)。所有模型线程都在同一个 OS 线程上,Rc 跨模型线程传递在 loom 里不会出事。实测(exp-loom/primitives,spawn_not_send):闭包捕获一个 Rc<i32>,编译通过,1 个交错。

这只影响编译期:被测代码在不开 --cfg loom 时仍用 std 的 spawn,Send 错误会在那时报出来。模型里为了图省事传了 Rc,换回 std 时才发现编不过,是这一条最常见的后果。

spawn_internal 做三件事:建一个 std Arc<Mutex<Option<Result<T>>>> 放结果;建一个不伪唤醒的 rt::Notify 给 join 用(Condvar、Notify 与 park);调 rt::spawn。rt::spawn(rt/mod.rs:67-84)先 execution.new_thread() 分配线程号、让子线程继承父线程的向量时钟(execution.rs:77-92),再把闭包交给 Scheduler::spawn 排队。协程在当前 tick 返回后才创建,spawn 调用本身没有 branch,不是调度点(协程调度器)。

join 恒为 Ok:子线程 panic 让模型失败

子线程体是 *result.lock().unwrap() = Some(Ok(f())),结果永远包成 Ok。f() panic 时没有任何 catch_unwind,panic 从子线程的协程一路传到 Scheduler::run,再传出 model。

实验 E15(exp-loom/survey/tests/misc.rs,e_thread_panic_join):子线程 panic!("boom in child"),主线程断言 join().is_err()。

环境结果
loom第 1 个交错,模型以子线程的 panic 消息失败:「boom in child」;主线程的断言根本没执行到
std(对照,exp-loom/primitives,e15_std_child_panic,同样的代码用 std::thread)子线程 panic 被打印,join() 返回 Err,断言成立

这是假阳性:std 下靠 join 返回 Err 来处理子线程失败的代码(线程池、监督者)在 loom 下测不了这条路径。绕法是在被测代码之外加一层:模型里的子线程自己 catch_unwind,把结果通过 loom 原语交回去。

没人 join 的线程也会跑完

一轮结束的条件是 threads.is_complete()(rt/thread.rs:237-252):没有活动线程时,断言所有线程都已 Terminated。主闭包返回之后,调度器还会继续调度其他线程。实测(exp-loom/primitives,unjoined_thread_runs):子线程不 join,9 个交错里子线程体 9 次都执行完。

这和 std 不同:std 里 main 返回进程就退出,没 join 的线程可能被直接终止。loom 里「主线程先结束、子线程后结束」也是被探索的交错之一。

最多 5 个线程

MAX_THREADS = 5(rt/mod.rs:61-62),包括主线程。0.6.0 的「Increase max threads to 5 (#314)」(a4341d0)之前是 4。线程表在 thread::Set::new 里用 Vec::with_capacity(max_threads) 建,new_thread 断言 self.threads.len() < self.max(),max() 就是这个容量(rt/thread.rs:214-231)。

实验 E12(exp-loom/survey/tests/misc.rs):

模型结果
主线程 spawn 4 个空线程并 join493 个交错,通过
主线程 spawn 5 个第 1 个交错「assertion failed: self.threads.len() < self.max()」(rt/thread.rs:215)

报错不提线程数,也不提 MAX_THREADS。Builder.max_threads 可以调小,不能调大:VersionVec 的分量数组和 FirstSeen 都是 [u16; MAX_THREADS] 定长(向量时钟与同步点)。Builder.max_threads 的文档写「must be less than MAX_THREADS」,而默认值就等于 MAX_THREADS,以代码为准(model 与 Builder)。

thread::current() 是懒初始化的:主线程第一次调用时才在线程局部表里登记(107-119 行)。ThreadId 的 Debug 输出是 ThreadId(n),n 是本轮内的线程号(0 是主线程),每一轮都一样。

thread_local!:存在线程对象里

loom::thread_local! 展开成一个 static NAME: loom::thread::LocalKey<T>,里面只有一个初始化函数指针(lib.rs:388-437)。值存在模型线程的 locals: HashMap<LocalKeyId, LocalValue> 里,键是 LocalKey 这个 static 的地址(rt/thread.rs:84-89)。try_with 第一次访问时在执行上下文外面跑初始化函数,再登记进去(thread.rs:261-281),所以初始化函数里可以再用 loom 类型。

线程结束时 thread_done 先把本线程所有局部值取出来,同样在执行上下文外 drop(rt/mod.rs:177-197),析构函数里也能碰 loom 类型;析构期间再访问同一个键得到 AccessError(「already destroyed」)。

E13 的一半(exp-loom/survey/tests/misc.rs,e_thread_local):一个带 Drop 计数的 RefCell<D>,主线程加 10、子线程加 1,各自读回。1 个交错,两边读到各自的 10 和 1,析构共 2 次。和 std 一致。

lazy_static!:每轮重来,可能初始化两次

loom::lazy_static! 是 lazy_static crate 的 mock。宏展开出的 Deref 里有一个 static LAZY: loom::lazy_static::Lazy<T>(lib.rs:439-474),值不存在这个 static 里,而是存在 Execution.lazy_statics(一张以 Lazy 地址为键的 HashMap,rt/lazy_static.rs)。三个后果:

每轮重新初始化、每轮结束 drop。 主闭包外面包了一层(model.rs:193-202):

scheduler.run(&mut execution, move || {
    f();

    let lazy_statics = rt::execution(|execution| execution.lazy_statics.drop());

    // drop outside of execution
    drop(lazy_statics);

    rt::thread_done();
});

每一轮都从空表开始,第一次访问时初始化;主闭包返回时整张表被拿走并 drop。真正的 lazy_static 不会 drop。这样做是为了每一轮互不影响(模型必须确定,写一个好模型),代价是析构函数会被执行,而生产环境里它永远不会执行。

主闭包返回后再访问会 panic。 表被拿走后 statics 是 None,get_static 里 expect("attempted to access lazy_static during shutdown")(39-47 行)。实测(exp-loom/primitives,lazy_unjoined_thread):子线程访问一个 lazy_static,主线程不 join,第 1 个交错就报这句。上一节说过没人 join 的线程会在主闭包返回后继续跑;它一碰 lazy_static 就失败。模型里要么 join 所有线程,要么别让晚结束的线程碰 lazy_static。

一轮里初始化可能执行两次。 lazy_static.rs:43-67:

match unsafe { self.try_get() } {
    Some(v) => v,
    None => {
        // Init the value out of the `rt::execution`
        let sv = crate::rt::lazy_static::StaticValue::new((self.init)());

        // While calling init, we may have yielded to the scheduler, in which case some
        // _other_ thread may have initialized the static. The real lazy_static does not
        // have this issue, since it takes a lock before initializing the new value, and
        // readers wait on that lock if they encounter it. We could implement that here
        // too, but for simplicity's sake, we just do another try_get here for now.
        if let Some(v) = unsafe { self.try_get() } {
            return v;
        }

        rt::execution(|execution| {
            let sv = execution.lazy_statics.init_static(self, sv);

            // lazy_static uses std::sync::Once, which does a swap(AcqRel) to set
            sv.sync.sync_store(&mut execution.threads, Ordering::AcqRel);
        });

        unsafe { self.try_get() }.expect("bug")
    }
}

注释自己承认了:初始化函数在执行上下文外跑,如果里面有调度点(任何 loom 操作),调度器可能切到另一个线程,那个线程看到表里还没有值,也去跑初始化。先登记的赢,后到的值被丢弃。同步方面模拟的是 Once:初始化完成是一次 AcqRel 写,try_get 是一次 Acquire 读,所以初始化函数里写的数据对后来的读者可见。

实验 E13(exp-loom/survey/tests/misc.rs 与 exp-loom/primitives/tests/prim.rs):初始化函数里给一个 std 计数器加一;主线程和一个子线程各解引用一次。

初始化函数交错数初始化执行次数值的析构次数
计数后 loom::thread::yield_now()122(被丢弃的那份和每轮结束时的那份)
不 yield(反向验证,e13_lazy_no_yield)11—

只有 1 个交错,是因为两个线程对 lazy_static 的访问本身不是调度点,模型里也没有别的相关操作,DPOR 没有可回溯的地方;yield_now 那一次切换发生在唯一的一条路径上。去掉 yield_now,初始化函数里没有调度点,就只执行一次。

这对被测代码意味着:初始化函数有副作用(计数、注册、分配 loom 对象)时,一轮里可能做两遍。分配 loom Arc 之类的对象时,被丢弃的那份照样会被正常 drop,不会报泄漏(E13 的析构计数是 2)。

crossbeam-epoch 的默认收集器在 loom 下就是 loom::lazy_static!(怎么验证并发代码),初始化函数里会建 loom 原子。crossbeam 卷的 E5 用的是 Collector::new(),不经过默认收集器(自定义收集器),不受这一条影响。

小结

  • spawn:没有 Send 约束(Rc 也能传);不是调度点;子线程继承父线程时钟。
  • join 恒 Ok;子线程 panic 直接让模型以那条 panic 消息失败(E15),std 下同样的代码 join 返回 Err。
  • 一轮要等所有线程终止;没人 join 的线程照样跑完(9 / 9)。
  • 最多 5 个线程含主线程:spawn 4 个 493 个交错通过,spawn 5 个报 self.threads.len() < self.max()(E12)。
  • thread_local!:值在模型线程的表里,执行上下文外初始化与析构,每线程一份(E13,析构 2 次)。
  • lazy_static!:值在 Execution 的表里,每轮重新初始化、主闭包返回时 drop;之后再访问报「attempted to access lazy_static during shutdown」;初始化里有调度点时一轮初始化两次,去掉调度点只一次(E13 与反向验证)。

💬 关联讨论

发起讨论
加载中...