Rust in Everything
基于 rayon-core 1.13.0

WorkerThread 主循环

主线位置:主线 1 的「B 被偷走,原线程边等边找活」和主线 3 的入口。worker 的主循环和 join 等 B 用的是同一个函数 wait_until,区别只是等的 latch 不同。

WorkerThread

registry.rs:647-663:

pub(super) struct WorkerThread {
    /// the "worker" half of our local deque
    worker: Worker<JobRef>,

    /// the "stealer" half of the worker's broadcast deque
    stealer: Stealer<JobRef>,

    /// local queue used for `spawn_fifo` indirection
    fifo: JobFifo,

    index: usize,

    /// A weak random number generator.
    rng: XorShift64Star,

    registry: Arc<Registry>,
}
字段说明
worker本地 deque 的主人端(!Sync,只能本线程用),push / pop
stealer本线程广播队列的窃取端,不是本地 deque 的;本地 deque 的 Stealer 在 Registry::thread_infos[index] 里给别人用
fifospawn_fifo 的间接层,JobRef 与几种 Job
index0..num_threads,current_thread_index() 返回的就是它
rng选窃取起点用的 xorshift*
registry所属的池

「当前线程是哪个池的哪个 worker」存在一个线程本地裸指针里(registry.rs:665-672):

// This is a bit sketchy, but basically: the WorkerThread is
// allocated on the stack of the worker on entry and stored into this
// thread-local variable. So it will remain valid at least until the
// worker is fully unwound. Using an unsafe pointer avoids the need
// for a RefCell<T> etc.
thread_local! {
    static WORKER_THREAD_STATE: Cell<*const WorkerThread> = const { Cell::new(ptr::null()) };
}

const 初始化让读取不需要懒初始化检查,WorkerThread::current() 就是一次线程本地读。set_current 断言原来是 null(一个线程只能属于一个池),WorkerThread 的 Drop 把它恢复成 null(registry.rs:687-713)。rayon-core 里几乎所有「我在哪」的判断都从这个指针出发:in_worker、inject_or_push、Registry::current、current_thread_index。

main_loop

registry.rs:910-939:

unsafe fn main_loop(thread: ThreadBuilder) {
    let worker_thread = &WorkerThread::from(thread);
    WorkerThread::set_current(worker_thread);
    let registry = &*worker_thread.registry;
    let index = worker_thread.index;

    // let registry know we are ready to do work
    Latch::set(&registry.thread_infos[index].primed);

    // Worker threads should not panic. If they do, just abort, as the
    // internal state of the thread pool is corrupted. Note that if
    // **user code** panics, we should catch that and redirect.
    let abort_guard = unwind::AbortIfPanic;

    // Inform a user callback that we started a thread.
    if let Some(ref handler) = registry.start_handler {
        registry.catch_unwind(|| handler(index));
    }

    worker_thread.wait_until_out_of_work();

    // Normal termination, do not abort.
    mem::forget(abort_guard);

    // Inform a user callback that we exited a thread.
    if let Some(ref handler) = registry.exit_handler {
        registry.catch_unwind(|| handler(index));
        // We're already exiting the thread, there's nothing else to do.
    }
}
  1. WorkerThread 放在本函数的栈上(&WorkerThread::from(..) 的临时值生命周期延长到函数结束),地址写进线程本地。
  2. set primed:build_global 在等的就是它。
  3. 装上 AbortIfPanic:之后到正常退出前,任何越过这里的 unwind 都会在守卫析构时打印「Rayon: detected unexpected panic; aborting」并 abort()(unwind.rs:24-31)。用户代码的 panic 都在更里层被 catch_unwind 接住,能走到这里的只有 rayon 自己的 bug。
  4. start_handler。
  5. wait_until_out_of_work:self.wait_until(&registry.thread_infos[index].terminate),然后 set stopped。

所以「主循环」并不是一个单独写的循环:worker 的一生就是等自己的 terminate latch,等的方式和 join 等 B 完全一样,在等待中执行找到的一切任务。

找活顺序

find_work:

    fn find_work(&self) -> Option<JobRef> {
        // Try to find some work to do. We give preference first
        // to things in our local deque, then in other workers
        // deques, and finally to injected jobs from the
        // outside. The idea is to finish what we started before
        // we take on something new.
        self.take_local_job()
            .or_else(|| self.steal())
            .or_else(|| self.registry.pop_injected_job())
    }

展开成四步:

flowchart LR
  a["1 本地 deque:worker.pop()"] -->|空| b["2 本线程广播队列:stealer.steal()"]
  b -->|空| c["3 随机起点轮询其他线程的本地 deque"]
  c -->|都空且无 Retry| d["4 全局注入队列 injected_jobs"]
  d -->|空| e["交给 sleep:no_work_found"]
  1. 本地 deque:worker.pop(),LIFO 下取最新推进去的任务(通常是自己刚 join 出去的 B)。
  2. 本线程的广播队列:take_local_job 在本地弹不出来时,接着从自己的广播队列 steal,Retry 就原地再试。广播任务只能由这个线程执行,所以排在偷别人之前。
  3. 偷其他线程:steal(),见下一节。
  4. 全局注入队列:pop_injected_job,Injector::steal 循环到 Success 或 Empty。

注释给的理由是「finish what we started before we take on something new」:已经在池里展开的计算(别人 deque 里的子任务)优先于外面新来的请求(install、池外的 join、spawn)。后果是:池子忙的时候,新注入的任务要等所有已展开的计算都被吃完才轮得到。crossbeam 卷 Injector 一页从队列的角度提过这一点。

steal:随机起点,Retry 整轮重来

WorkerThread::steal 的代码和 Steal 三态的语义,crossbeam 卷 Steal、Retry 与批量窃取 已经逐行讲过:从 rng.next_usize(num_threads) 开始绕一圈,跳过自己,第一个 Success 就返回;一圈都没偷到但遇到过 Retry,再绕一圈;没有 Retry 就返回 None。这里补三点 crossbeam 卷没讲的:

  • 开头的两行:debug_assert!(self.local_deque_is_empty()),只有本地没活才该来偷;num_threads <= 1 直接返回 None,单线程池不做无意义的轮询。
  • 为什么 Retry 要整轮重来而不是对同一个受害者原地重试:这是 2020 年的一次微优化,2e88929「Micro-optimize the WorkerThread::steal loop」(与 #785、#790、#791 一起合并,a0e5833)。提交说明:「This lifts up the loop for Steal::Retry, so we continue with other threads before trying again」,大部分基准变化在噪声内,join_microbench::increment_all_max 最多快 8%。直觉是:Retry 说明有人在和你抢同一个队列,与其挤在那里,不如先看看别的队列。
  • 随机数:XorShift64Star,种子是一个全局 AtomicUsize 计数器 fetch_add(1) 后的 DefaultHasher 哈希,循环直到非零。每个 worker 的种子不同,不需要系统随机源。这是 2018 年 b0b6c13「Implement our own PRNG for rayon-core」引入的。

rayon 只用单个 steal(),不用 steal_batch。

wait_until_cold:双层循环

registry.rs:769-817:

    #[inline]
    pub(super) unsafe fn wait_until<L: AsCoreLatch + ?Sized>(&self, latch: &L) {
        let latch = latch.as_core_latch();
        if !latch.probe() {
            self.wait_until_cold(latch);
        }
    }

    #[cold]
    unsafe fn wait_until_cold(&self, latch: &CoreLatch) {
        // the code below should swallow all panics and hence never
        // unwind; but if something does wrong, we want to abort,
        // because otherwise other code in rayon may assume that the
        // latch has been signaled, and that can lead to random memory
        // accesses, which would be *very bad*
        let abort_guard = unwind::AbortIfPanic;

        'outer: while !latch.probe() {
            // Check for local work *before* we start marking ourself idle,
            // especially to avoid modifying shared sleep state.
            if let Some(job) = self.take_local_job() {
                self.execute(job);
                continue;
            }

            let mut idle_state = self.registry.sleep.start_looking(self.index);
            while !latch.probe() {
                if let Some(job) = self.find_work() {
                    self.registry.sleep.work_found();
                    self.execute(job);
                    // The job might have injected local work, so go back to the outer loop.
                    continue 'outer;
                } else {
                    self.registry
                        .sleep
                        .no_work_found(&mut idle_state, latch, || self.has_injected_job())
                }
            }

            // If we were sleepy, we are not anymore. We "found work" --
            // whatever the surrounding thread was doing before it had to wait.
            self.registry.sleep.work_found();
            break;
        }

        mem::forget(abort_guard); // successful execution, do not abort
    }
  • 快路径:wait_until 是 #[inline] 的,只做一次 probe()(load(Acquire));已经 set 就不进冷函数。
  • 外层:每轮先看本地。有本地活就直接执行,不碰共享的 sleep 状态(注释特意强调)——本地有活的线程不应该被算作 idle。
  • 内层:本地没活了,才 start_looking(inactive 计数 +1),然后反复 find_work。找到就 work_found(inactive -1,必要时叫醒别人)、执行,然后 continue 'outer:刚执行的任务可能又往本地推了任务(比如它自己做了 join),所以回到外层先看本地。找不到就 no_work_found,由 sleep 模块决定是 yield_now 还是去睡(一个线程睡下又醒来)。
  • latch 被 set:内层循环条件不满足,work_found 把自己从 inactive 里减掉,退出。
  • 整个函数被 AbortIfPanic 包着:执行的任务都是 JobRef::execute,各种 Job 的 execute 内部都已 catch_unwind;若仍有 panic 穿出来,说明 rayon 内部状态已不可信,与其让调用方以为 latch 已 set 继续访问内存,不如直接 abort。

has_injected_job 是睡前最后一次检查用的闭包:本线程广播队列非空,或全局注入队列非空(registry.rs:765-767)。前一半是 rayon-core 1.10.1 修一个广播竞态时加的(be12615),见 broadcast。

等待时执行的任务从哪来

wait_until_cold 会执行它找到的任何任务:本地的、广播的、偷来的、注入的。对 worker 主循环来说这理所当然;对 join 等 B 来说,就意味着:

  • 线程 W0 在 join 里等 B 时,可能偷来另一个与当前调用栈毫无关系的任务,在 W0 的栈上、在当前 join 的栈帧之上执行它。
  • 这个任务若也去拿 W0 正持有的锁,就是同一线程重入同一把 std::sync::Mutex:死锁或 panic。
  • 依赖线程局部变量的代码,也会在「不属于它的」调用栈上看到前一个任务留下的值。

规划阶段的实验(E7,exp-rayon/survey 的 lock_reentry)用线程局部的深度计数做了确定性的检测:外层 200 个元素的 for_each、每个元素里再跑一个 2000 元素的 par_iter().sum(),不加锁,统计「外层任务在另一个外层任务的栈上开始执行」的次数。规划时一次运行是 48 次;写作时重跑 3 次是 27、20、87 次,每次不同,但从没有 0。反向验证:RAYON_NUM_THREADS=1 下 3 次都是 0。只有一个线程时没有窃取,等 B 时从本地弹出来的总是自己刚推的子任务,不会执行别的外层元素。持锁版本的死锁复现依赖时序,放在 09 常见坑。等待时干活换来的是:嵌套多深的 join 都不会耗尽线程、不需要为等待单独开线程。这条取舍在 08 设计取舍 汇总。

yield_now 与 yield_local

registry.rs:844-862:yield_now 就是 find_work 一次、找到就执行;yield_local 只看 take_local_job。它们把「主循环里的一步」暴露给用户,在 rayon 任务里需要轮询等待某个条件时用,避免空转。细节在 yield_now 与 yield_local。

小结

  • WorkerThread 在 worker 栈上,地址存进 const 初始化的线程本地 Cell<*const WorkerThread>;它的 stealer 是本线程广播队列的,本地 deque 的 Stealer 在 Registry 里。
  • main_loop = set primed → start_handler → wait_until(terminate) → exit_handler,外面一个 AbortIfPanic。
  • 找活:本地 deque → 本线程广播队列 → 随机起点偷其他线程(Retry 整轮重来,num_threads <= 1 不偷)→ 全局注入队列。外面新来的任务排在最后。
  • wait_until_cold:外层先看本地且不碰 sleep 状态,内层 find_work / no_work_found;执行任务后回外层。
  • 等待时会执行任意任务,这让嵌套 join 不会耗尽线程,也让持锁调 rayon、依赖 TLS 的代码出问题(E7:12 线程每次运行 20–87 次跨任务重入,1 线程为 0)。

💬 关联讨论

发起讨论
加载中...