线程、线程局部与 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 约束
#[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 个空线程并 join | 493 个交错,通过 |
| 主线程 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() | 1 | 2 | 2(被丢弃的那份和每轮结束时的那份) |
不 yield(反向验证,e13_lazy_no_yield) | 1 | 1 | — |
只有 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 与反向验证)。