Rust in Everything
基于 rayon-core 1.13.0

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:为什么收裸指针

latch.rs 第 35–55 行:

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 提了两条要求:

  1. probe() 返回真之后,set() 之前的写入都可见(set 与 probe 同步)。
  2. 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.rs 第 57–136 行:

/// 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() 里调用,次序是:

  1. get_sleepy:UNSET → SLEEPY。失败说明 latch 已经是 SET,不睡了。
  2. 拿住自己的 is_blocked 锁。
  3. fall_asleep:SLEEPY → SLEEPING。失败说明在 1、3 之间被设置了,不睡了。
  4. 登记为 sleeping、最后检查一次,然后在 Condvar 上等。
  5. 醒来后 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 用的那一种

latch.rs 第 148–223 行:

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 的线程

latch.rs 第 228–267 行:

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 的计数器

latch.rs 第 315–424 行:

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:借用但不承诺有效

latch.rs 第 426–457 行:

/// `&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闭包已执行,结果已写入 resultmem::forget 一个局部守卫
SpinLatch::set&Registry(跨池时是 Arc 克隆)、target_worker_index用这两个值叫醒主人
CountLatch::setArc::clone(registry)、worker_index同上
OnceLatch::set_and_tickle_oneregistry 和下标由调用方传入(latch 在 registry 里,registry 由调用方保活)叫醒
LockLatch::set—持锁 notify_all,然后放锁;放锁前等待方不会返回
ScopeBase::execute_job_closurepanic 已存入 scope.panic返回闭包的结果(局部变量)

这些顺序没有一条能被类型系统检查,单次运行也很难暴露:读错了顺序的版本在绝大多数时候照样能跑,因为等待方被唤醒、返回、覆盖栈帧需要时间。a46e1f8 的问题就是 miri 找到的。

小结

  • Latch::set 收 *const Self:set 是 latch 生命周期的最后一刻,&self 会在调用期间悬空(a46e1f8,#1011,miri 发现)。
  • CoreLatch 把「被设置」和主人的「要睡 / 睡着 / 醒了」放进同一个原子变量,设置方 swap 带回旧值,只有旧值是 SLEEPING 才需要叫醒;同一变量上读-改-写的全序加上主人睡前持有的 is_blocked 锁,保证信号不丢。
  • SpinLatch 给 join,主人是 worker,跨池时先 Arc::clone registry;LockLatch 给池外线程,直接阻塞;OnceLatch 给终止信号,registry 由调用方传;CountLatch 给 scope / broadcast,减到 0 才设置,Stealing 变体存主人所在池的 Arc;LatchRef 让 StackJob 借用别处的 latch 而不持有引用。
  • 每个 set 实现都在设置之前读完要用的字段,之后只用局部变量。

💬 关联讨论

发起讨论
加载中...