Latch 家族
主线位置:一次 join 的后半,以及「设置之后不能再碰」那条横线。窃取者跑完 B,
StackJob::execute调Latch::set(&job_b.latch);SpinLatch::set先把 registry 和目标线程号读出来,再swap(SET),旧值是SLEEPING才去叫醒原线程。原线程在wait_until里看到probe()为真,取走结果返回。
stateDiagram-v2 [*] --> UNSET UNSET --> SLEEPY: get_sleepy(主人,CAS) SLEEPY --> SLEEPING: fall_asleep(主人,CAS) SLEEPING --> UNSET: wake_up(主人醒来,CAS) UNSET --> SET: set(任何线程,swap) SLEEPY --> SET: set SLEEPING --> SET: set,返回 true,需要叫醒 SET --> [*]
latch 是 rayon 里「一个线程等另一个线程做完某件事」的统一说法:开始是假,某个线程 set 一次变成真,等的人反复 probe,看到真之后,set 之前的写入都可见。latch.rs 457 行,一个 trait、一个核心状态机、五种 latch。
Latch trait:为什么收裸指针
pub(super) trait Latch {
/// Set the latch, signalling others.
///
/// # WARNING
///
/// Setting a latch triggers other threads to wake up and (in some
/// cases) complete. This may, in turn, cause memory to be
/// deallocated and so forth. One must be very careful about this,
/// and it's typically better to read all the fields you will need
/// to access *before* a latch is set!
///
/// This function operates on `*const Self` instead of `&self` to allow it
/// to become dangling during this call. The caller must ensure that the
/// pointer is valid upon entry, and not invalidated during the call by any
/// actions other than `set` itself.
unsafe fn set(this: *const Self);
}
pub(super) trait AsCoreLatch {
fn as_core_latch(&self) -> &CoreLatch;
}
set 是 latch 生命中的最后一个动作,也是它可能被销毁的那一刻:join 的 B 被偷走后,B 的 SpinLatch 在 join_context 的栈帧上;窃取者一 set,原线程就可能看到、取结果、函数返回,栈帧没了。如果 set 的签名是 &self,那这个引用在 set 执行期间就悬空了。
这个签名是 a46e1f8「Use pointers instead of &self in Latch::set」(2023-01,合并于 #1011)改的。提交说明讲了来龙去脉:rustc 给函数参数里的引用加 LLVM 的 dereferenceable 属性(编译器可以假定这块内存在调用期间可读,见 rust-lang/rust#55005 的讨论),rust-lang/rust#98017 之后,对含 UnsafeCell 的类型(!Freeze)不再加;但 miri 的 Stacked Borrows 规则更细,只对 cell 本身放宽,latch 里的其他字段仍然算悬空引用。最简单的修法就是改成裸指针。同一个 PR 里的 f880d02 加了 LatchRef(本页最后一节)。
AsCoreLatch 是另一半:能被 worker wait_until 的 latch,都要能拿出一个 CoreLatch 来 probe,睡觉的协议也只认 CoreLatch。LockLatch 没有实现它(它有自己的阻塞方式)。
两条内存序要求
trait 上方的文档(第 25–34 行)给 latch 提了两条要求:
probe()返回真之后,set()之前的写入都可见(set 与 probe 同步)。set()发生之后,下一次probe()一定看得到。注释说这「通常需要 seq-cst」,并链到 sleep README 的「tickle-then-get-sleepy」一节。
第 2 条的链接指向的那一节在当前的 sleep/README.md 里已经不存在了。它属于 2020 年以前的旧睡眠方案(全池一个 state 原子,见 05 sleep 模块的「演进」一节),旧 README 第 307 行起的「Scenario 2: tickle-then-get-sleepy」论证了 latch 操作为什么也必须是 SeqCst。新方案把「latch 被设置」和「主人要睡了」放进了同一个原子变量,论证方式换了,见下一节。
CoreLatch:一个原子上的四个状态
/// Latch is not set, owning thread is awake
const UNSET: usize = 0;
/// Latch is not set, owning thread is going to sleep on this latch
/// (but has not yet fallen asleep).
const SLEEPY: usize = 1;
/// Latch is not set, owning thread is asleep on this latch and
/// must be awoken.
const SLEEPING: usize = 2;
/// Latch is set.
const SET: usize = 3;
pub(super) struct CoreLatch {
state: AtomicUsize,
}
impl CoreLatch {
pub(super) fn get_sleepy(&self) -> bool {
self.state
.compare_exchange(UNSET, SLEEPY, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
}
pub(super) fn fall_asleep(&self) -> bool {
self.state
.compare_exchange(SLEEPY, SLEEPING, Ordering::SeqCst, Ordering::Relaxed)
.is_ok()
}
pub(super) fn wake_up(&self) {
if !self.probe() {
let _ =
self.state
.compare_exchange(SLEEPING, UNSET, Ordering::SeqCst, Ordering::Relaxed);
}
}
unsafe fn set(this: *const Self) -> bool {
let old_state = (*this).state.swap(SET, Ordering::AcqRel);
old_state == SLEEPING
}
pub(super) fn probe(&self) -> bool {
self.state.load(Ordering::Acquire) == SET
}
}
四个状态里,SET 由「做事的一方」写,其余三个只由 latch 的主人(在这个 latch 上 wait_until 的那个 worker)写。主人的三次转换都是 CAS,只在预期的旧状态上成功;设置方用的是无条件的 swap,并把旧值带回来。
get_sleepy、fall_asleep、wake_up 只在 sleep 模块的 sleep() 里调用,次序是:
get_sleepy:UNSET → SLEEPY。失败说明 latch 已经是SET,不睡了。- 拿住自己的
is_blocked锁。 fall_asleep:SLEEPY → SLEEPING。失败说明在 1、3 之间被设置了,不睡了。- 登记为 sleeping、最后检查一次,然后在
Condvar上等。 - 醒来后
wake_up:如果还没被设置,SLEEPING → UNSET。
设置方的 swap(SET) 可能落在这串动作的任何位置。因为所有动作都是同一个原子变量上的读-改-写,它们在这个变量的修改顺序里排成一列,每个读-改-写读到的都是它前一个写入的值,于是只有三种情况:
swap(SET) 排在 | swap 读到 | 设置方做什么 | 主人发生什么 |
|---|---|---|---|
get_sleepy 之前 | UNSET | 什么也不做 | get_sleepy 的 CAS 看到 SET,失败,回去 probe,看到真 |
get_sleepy 与 fall_asleep 之间 | SLEEPY | 什么也不做 | fall_asleep 的 CAS 看到 SET,失败,回去 probe |
fall_asleep 之后 | SLEEPING | 去叫醒主人:notify_worker_latch_is_set | 主人要么已经在 Condvar 上等,要么正要等,要么自己放弃了睡眠(见下) |
第三种情况的关键在于第 2 步那把锁:主人从 fall_asleep 之前一直拿着自己的 is_blocked 锁,直到 Condvar::wait 才放开;叫醒方的 wake_specific_thread 要先拿同一把锁才能看 is_blocked。所以叫醒方拿到锁时,主人要么已经把 is_blocked 置为真并在等(叫醒方把它改回假、notify_one),要么已经因为别的原因放弃了睡眠、放了锁(is_blocked 是假,叫醒方什么也不做,主人醒着,下一轮就 probe 到)。这一段在 睡眠与唤醒的握手 里逐行展开。
这里「不丢信号」靠的是单个原子变量上读-改-写的全序,而不是 SeqCst:swap 写的是 AcqRel,CAS 的成功序写的是 SeqCst,换成 AcqRel 不影响上面三种情况的划分(推测:上游没有说明这里保留 SeqCst 的理由,可能是沿用旧方案的写法)。swap 的 Release 一半和 probe 的 Acquire 配对,负责第 1 条要求:B 的结果在 set 之前写进 StackJob::result,主人 probe 到 SET 之后读结果,一定读到。
wake_up 上的文档注释(「Returns true if the owning thread should block」)是从 fall_asleep 复制过来的,wake_up 实际不返回值。
SpinLatch:join 用的那一种
pub(super) struct SpinLatch<'r> {
core_latch: CoreLatch,
registry: &'r Arc<Registry>,
target_worker_index: usize,
cross: bool,
}
impl Latch for SpinLatch<'_> {
#[inline]
unsafe fn set(this: *const Self) {
let registry: &Registry = if (*this).cross {
// Ensure the registry stays alive while we notify it.
// Otherwise, it would be possible that we set the spin
// latch and the other thread sees it and exits, causing
// the registry to be deallocated, all before we get a
// chance to invoke `registry.notify_worker_latch_is_set`.
&Arc::clone((*this).registry)
} else {
// If this is not a "cross-registry" spin-latch, then the
// thread which is performing `set` is itself ensuring
// that the registry stays alive. However, that doesn't
// include this *particular* `Arc` handle if the waiting
// thread then exits, so we must completely dereference it.
(*this).registry
};
let target_worker_index = (*this).target_worker_index;
// NOTE: Once we `set`, the target may proceed and invalidate `this`!
if CoreLatch::set(&(*this).core_latch) {
// Subtle: at this point, we can no longer read from
// `self`, because the thread owning this spin latch may
// have awoken and deallocated the latch. Therefore, we
// only use fields whose values we already read.
registry.notify_worker_latch_is_set(target_worker_index);
}
}
}
名字叫「spin」,其实主人等它的方式是 wait_until:边找活边 probe,找不到活就按 sleep 模块的协议睡下。它记着主人是谁(registry + target_worker_index),set 时只在主人真的睡着(旧值 SLEEPING)时才去叫醒;主人醒着时,set 就是一次 swap。
set 里的三行读取(cross、registry、target_worker_index)都在 CoreLatch::set 之前,这就是 trait 文档里「先把要用的字段读出来」的样子。两个分支的区别:
- 同一个池(
SpinLatch::new,join用):设置方自己就是这个池的 worker,它的WorkerThread里持有这个 registry 的Arc,registry 不会在它手里消失。但(*this).registry是一个&Arc<Registry>,指向主人WorkerThread里的那个Arc句柄;主人退出后这个句柄就没了。所以注释要求「完全解引用」:let registry: &Registry = (*this).registry;在set之前就从句柄里取出了Registry的地址,之后只用这个地址。 - 跨池(
SpinLatch::cross,in_worker_cross用):设置方是 B 池的线程,主人是 A 池的线程,registry是 A 池的。设置方手里没有 A 的任何Arc,主人一醒就可能返回、A 池被 drop。所以先Arc::clone一份放在临时变量里,保证notify_worker_latch_is_set期间 A 池还活着。这个分支来自cbd484c「Keep the registry alive for a cross-pool SpinLatch」(2020-03,随 #746 的新调度器一起合并)。
'r 生命周期把 SpinLatch 绑在 &WorkerThread 上,它只能在 worker 的栈帧里创建,这就是「主人一定是 worker」的类型表达。
LockLatch:给不是 worker 的线程
pub(super) struct LockLatch {
m: Mutex<bool>,
v: Condvar,
}
impl LockLatch {
pub(super) const fn new() -> LockLatch { /* ... */ }
/// Block until latch is set, then resets this lock latch so it can be reused again.
pub(super) fn wait_and_reset(&self) {
let mut guard = self.m.lock().unwrap();
while !*guard {
guard = self.v.wait(guard).unwrap();
}
*guard = false;
}
/// Block until latch is set.
pub(super) fn wait(&self) { /* 同上,不 reset */ }
}
impl Latch for LockLatch {
unsafe fn set(this: *const Self) {
let mut guard = (*this).m.lock().unwrap();
*guard = true;
(*this).v.notify_all();
}
}
最朴素的一种:Mutex<bool> 加 Condvar。等的线程不参与窃取,直接阻塞。它没有实现 AsCoreLatch,不能被 wait_until。用在三处:
- 池外线程进池:
in_worker_cold里有一个thread_local!的LockLatch(const初始化,所以new是const fn),每次进池复用,等完用wait_and_reset置回false。pytorch-to-candle 卷采样到的__psynch_cvwait栈顶,就是主线程睡在这里(见 in_worker:从哪进池)。 - 每个线程的
primed、stopped:线程起来、线程退出时各set一次,wait_until_primed等所有线程就绪。 CountLatch的Blocking变体,下面讲。
set 在持锁期间 notify_all,guard 在函数末尾才释放。等待方被唤醒后要重新拿到这把锁才能从 wait 返回,所以在 set 放锁之前,等待方不可能返回并销毁 latch。
OnceLatch:终止信号
latch.rs 第 269–313 行:每个线程一个,放在 ThreadInfo::terminate 里。worker 主循环最后一件事就是 wait_until(&terminate)(这里也用 wait_until:等终止的同时继续干活,没活就睡)。Registry::terminate 在 terminate_count 减到 0 时,对每个线程调一次:
pub(super) unsafe fn set_and_tickle_one(
this: *const Self,
registry: &Registry,
target_worker_index: usize,
) {
if CoreLatch::set(&(*this).core_latch) {
registry.notify_worker_latch_is_set(target_worker_index);
}
}
它和 SpinLatch 的区别是不自己存 registry:OnceLatch 本身就在 Registry 里,再存一个 Arc<Registry> 就成了引用环。所以 registry 由调用方传进来,它也就没有实现 Latch trait(set 只有一个参数)。
CountLatch:scope 和 broadcast 的计数器
pub(super) struct CountLatch {
counter: AtomicUsize,
kind: CountLatchKind,
}
enum CountLatchKind {
/// A latch for scopes created on a rayon thread which will participate in work
/// stealing while it waits for completion. This thread is not necessarily part
/// of the same registry as the scope itself!
Stealing {
latch: CoreLatch,
registry: Arc<Registry>,
worker_index: usize,
},
/// A latch for scopes created on a non-rayon thread which will block to wait.
Blocking { latch: LockLatch },
}
impl CountLatch {
pub(super) fn increment(&self) {
let old_counter = self.counter.fetch_add(1, Ordering::Relaxed);
debug_assert!(old_counter != 0);
}
}
impl Latch for CountLatch {
unsafe fn set(this: *const Self) {
if (*this).counter.fetch_sub(1, Ordering::SeqCst) == 1 {
// NOTE: Once we call `set` on the internal `latch`,
// the target may proceed and invalidate `this`!
match (*this).kind {
CountLatchKind::Stealing {
ref latch,
ref registry,
worker_index,
} => {
let registry = Arc::clone(registry);
if CoreLatch::set(latch) {
registry.notify_worker_latch_is_set(worker_index);
}
}
CountLatchKind::Blocking { ref latch } => LockLatch::set(latch),
}
}
}
}
CountLatch 的 set 只是「减一」,减到 0 才真正设置内部的 latch。scope 建它时计数为 1(代表 scope 的主体闭包 op 自己),每 spawn 一个任务先 increment,每个任务结束 set 一次,op 结束再 set 一次;broadcast 直接用 with_count(线程数) 建。
几处细节:
increment可以用 Relaxed。调用increment的一方自己一定还占着一个计数(scope 的主体还没结束,或者正在跑的任务还没set),计数器不可能在此期间归零,debug_assert!(old_counter != 0)检查的就是这一点。没有别的数据要靠这次加法发布。set用 SeqCst 的fetch_sub。每个任务对共享数据的写入要对最后归零的那一方可见;读-改-写本身既是获取也是释放,所有fetch_sub组成一条释放序列,最后一个减到 0 的线程看得到前面所有任务的写入,再经内部 latch 的swap(AcqRel)/probe(Acquire)传给等待方。scope 收集 panic 时能用 Relaxed 的swap(scope/mod.rs第 723–732 行,注释「all outstanding jobs have completed, so we can use a relaxed ordering」)靠的就是这条链。Stealing存的是Arc<Registry>,而且是主人的 registry。注释举了例子:A 池的 worker 对 B 池调in_place_scope,scope 的任务在 B 池跑,最后一个任务在 B 池的线程上set,要叫醒的是 A 池的线程,此时手里只有 B 池的引用。所以建 latch 时把主人所在 registry 的Arc存进来;set时再Arc::clone一份到局部变量,原因和SpinLatch::cross一样:内部 latch 一设置,CountLatch本身就可能随 scope 的栈帧消失。Blocking:主人不是 worker(池外线程调in_place_scope或broadcast),用LockLatch阻塞。
现在的「一个计数器字段 + 一个只在归零时才看的枚举」结构来自 32d3774「Refactor scope latches to reduce matching」(2023-06,合并于 #1059)。提交说明:以前的 enum ScopeLatch 让 increment 和 set 每次都要 match,而两个分支大多数时候都只是改一个 AtomicUsize,set 里的分支在 perf 采样里看得见;改成统一的 counter 字段后,绝大多数更新不进 match。
LatchRef:借用但不承诺有效
/// `&L` without any implication of `dereferenceable` for `Latch::set`
pub(super) struct LatchRef<'a, L> {
inner: *const L,
marker: PhantomData<&'a L>,
}
impl<L: Latch> Latch for LatchRef<'_, L> {
unsafe fn set(this: *const Self) {
L::set((*this).inner);
}
}
StackJob 的 latch 类型参数要求 L: Latch。有时 latch 不在 StackJob 里而是在别处:in_worker_cold 的 latch 在线程本地,broadcast 的 CountLatch 在调用方栈上、被 N 个 StackJob 共用。直接用 &L 当 latch 类型,StackJob 里就存着一个引用,而这个引用在最后一次 set 之后可能悬空,正是 a46e1f8 要消除的情况。LatchRef 存裸指针,用 PhantomData<&'a L> 保留生命周期和 Sync 推导,set 时把裸指针原样往下传。
「设置之后不能再碰」逐个对照
| 位置 | set 之前读出的东西 | set 之后还做什么 |
|---|---|---|
StackJob::execute | 闭包已执行,结果已写入 result | mem::forget 一个局部守卫 |
SpinLatch::set | &Registry(跨池时是 Arc 克隆)、target_worker_index | 用这两个值叫醒主人 |
CountLatch::set | Arc::clone(registry)、worker_index | 同上 |
OnceLatch::set_and_tickle_one | registry 和下标由调用方传入(latch 在 registry 里,registry 由调用方保活) | 叫醒 |
LockLatch::set | — | 持锁 notify_all,然后放锁;放锁前等待方不会返回 |
ScopeBase::execute_job_closure | panic 已存入 scope.panic | 返回闭包的结果(局部变量) |
这些顺序没有一条能被类型系统检查,单次运行也很难暴露:读错了顺序的版本在绝大多数时候照样能跑,因为等待方被唤醒、返回、覆盖栈帧需要时间。a46e1f8 的问题就是 miri 找到的。
小结
Latch::set收*const Self:set是 latch 生命周期的最后一刻,&self会在调用期间悬空(a46e1f8,#1011,miri 发现)。CoreLatch把「被设置」和主人的「要睡 / 睡着 / 醒了」放进同一个原子变量,设置方swap带回旧值,只有旧值是SLEEPING才需要叫醒;同一变量上读-改-写的全序加上主人睡前持有的is_blocked锁,保证信号不丢。SpinLatch给join,主人是 worker,跨池时先Arc::cloneregistry;LockLatch给池外线程,直接阻塞;OnceLatch给终止信号,registry 由调用方传;CountLatch给scope/broadcast,减到 0 才设置,Stealing变体存主人所在池的Arc;LatchRef让StackJob借用别处的 latch 而不持有引用。- 每个
set实现都在设置之前读完要用的字段,之后只用局部变量。