Rust in Everything
基于 rayon-core 1.13.0

一次 join

主线位置:主线 1 的全程。本页只画时序和关键行,细节在后面:in_worker、WorkerThread 主循环、JobRef 与几种 Job、Latch 家族、join 与 join_context。deque 一侧的同一件事,crossbeam 卷已经从 Worker::push / Stealer::steal 的角度走过一遍:一个任务。

rayon::join(a, b) 只是把两个 FnOnce() -> R 包成 FnOnce(FnContext) -> R,调用 join_context(join/mod.rs:93-106)。全部逻辑在 join_context 里,不到 60 行:

    registry::in_worker(|worker_thread, injected| unsafe {
        // Create virtual wrapper for task b; this all has to be
        // done here so that the stack frame can keep it all live
        // long enough.
        let job_b = StackJob::new(call_b(oper_b), SpinLatch::new(worker_thread));
        let job_b_ref = job_b.as_job_ref();
        let job_b_id = job_b_ref.id();
        worker_thread.push(job_b_ref);

        // Execute task a; hopefully b gets stolen in the meantime.
        let status_a = unwind::halt_unwinding(call_a(oper_a, injected));
        let result_a = match status_a {
            Ok(v) => v,
            Err(err) => join_recover_from_panic(worker_thread, &job_b.latch, err),
        };

        // Now that task A has finished, try to pop job B from the
        // local stack.  It may already have been popped by job A; it
        // may also have been stolen. There may also be some tasks
        // pushed on top of it in the stack, and we will have to pop
        // those off to get to it.
        while !job_b.latch.probe() {
            let Some(job) = worker_thread.take_local_job() else {
                // Local deque is empty. Time to steal from other
                // threads.
                worker_thread.wait_until(&job_b.latch);
                debug_assert!(job_b.latch.probe());
                break;
            };
            if job_b_id == job.id() {
                // Found it! Let's run it.
                //
                // Note that this could panic, but it's ok if we unwind here.
                let result_b = job_b.run_inline(injected);
                return (result_a, result_b);
            }
            worker_thread.execute(job);
        }

        (result_a, job_b.into_result())
    })

时序

sequenceDiagram
  participant C as 调用线程 W0
  participant D as W0 的本地 deque
  participant S as sleep 计数器
  participant T as 窃取者 W1
  C->>C: in_worker:W0 是本池 worker,直接调用闭包
  C->>C: 栈上建 job_b = StackJob{SpinLatch, func, result}
  C->>D: push(job_b_ref)
  C->>S: new_internal_jobs(1, queue_was_empty),有人睡着才考虑叫醒
  C->>C: halt_unwinding 跑 A
  alt B 没被偷
    C->>D: take_local_job 弹出 B,id 相同
    C->>C: run_inline(injected),直接调用闭包,不碰 latch
  else B 被偷走
    T->>D: Stealer::steal 拿到 job_b_ref
    T->>T: StackJob::execute:跑 B,结果写进 job_b.result
    C->>D: take_local_job 返回 None
    C->>C: wait_until(job_b.latch):本地、偷、注入队列、找不到就睡
    T->>C: Latch::set:swap(SET),旧值是 SLEEPING 才唤醒 W0
    C->>C: into_result 取出 B 的结果
  end

逐步

1. 进池。 registry::in_worker(registry.rs:946-962)读线程本地的 WORKER_THREAD_STATE:当前线程是任意一个池的 worker,就在当前线程上直接调用闭包,injected = false;不是,就把整个闭包注入全局池,在 LockLatch 上等。后一条路径(「冷启动」)一次要十几微秒,in_worker:从哪进池 专门讲。下面假设已经在 worker W0 上。

2. B 放在栈上。 StackJob(job.rs:72-81)就是三个字段:latch、func: UnsafeCell<Option<F>>、result: UnsafeCell<JobResult<R>>。它在 join_context 的栈帧里,不分配堆内存。推进 deque 的是 JobRef:一个数据指针加一个 execute_fn 函数指针,16 字节,相当于手写的 dyn Job(job.rs:33-36)。栈帧在 join_context 返回前不会消失,而 join_context 在 B 完成前不会返回,这是「栈上任务」能成立的前提。

3. push 并通知 sleep 模块。 WorkerThread::push(registry.rs:728-732)先记下本地队列原来是不是空的,Worker::push,再调 sleep.new_internal_jobs(1, queue_was_empty)。后者在没有线程睡着时几乎立刻返回;有线程睡着时按 一个线程睡下又醒来 里的规则决定叫不叫醒。

4. 先跑 A。 A 在本线程上跑,用 halt_unwinding(即 catch_unwind)包住。为什么先推 B 再跑 A:A 继续用着刚才的缓存;B 在 deque 里等着被偷,LIFO deque 的窃取者拿的是最老的任务,而递归切分时最老的任务就是最大的那块(Chase-Lev 双端队列)。

5. 回头找 B。 A 返回后,B 可能在三个地方:

  • 还在本地 deque 顶上:take_local_job 弹出来的就是它。怎么认出来?JobRef::id() 返回 (pointer, execute_fn),和 push 前记下的 job_b_id 比较(job.rs:58-60)。认出来就 run_inline:直接取出闭包调用,结果直接返回,latch 从头到尾没被碰过。这是最常见、也最便宜的路径——没有任何跨线程同步。
  • 被 A 留下的子任务压在下面:A 内部的 join 会推自己的 B',正常情况下 A 返回前都已处理完;但如果 A 里有 spawn 之类留下的任务,弹出的就不是 B,那就 execute 它,继续弹。
  • 已经被偷走:本地 deque 空了,latch.probe() 还不是 SET,就 wait_until(&job_b.latch)。

6. 等的时候干活。 wait_until(registry.rs:772-777)先 probe 一次,没 set 才进 wait_until_cold:外层循环每轮先看本地 deque,内层循环按「本地 → 偷别的线程 → 全局注入队列」找活,找到就执行,找不到就交给 sleep 模块记一轮空转(WorkerThread 主循环)。W0 等 B 的时候并不闲着,它可能去偷 W1 的任务,甚至偷回 B 的子任务。这就是嵌套的 join 不会耗尽线程的原因,也是「任意任务可能在任意等待者的栈上执行」的来源。

7. 窃取者执行 B。 W1 偷到 job_b_ref,调用 StackJob::execute(job.rs:116-123):取出闭包,JobResult::call(func) 以 true 调用它并捕获 panic,结果写进 result,然后才 Latch::set(&this.latch)。顺序不能反:set 之后 W0 随时可能读 result 并让整个栈帧消失。

8. set 与唤醒。 SpinLatch::set(latch.rs:194-222)先把 registry 和 target_worker_index 读到局部变量,再 CoreLatch::set:state.swap(SET, AcqRel),旧值是 SLEEPING 才返回 true。只有 W0 已经睡在 Condvar 上时,W1 才去 notify_worker_latch_is_set(0) 叫醒它;W0 还在自旋找活时,下一次 probe()(load(Acquire))自己就会看到 SET。读字段要在 set 之前,因为 set 之后 this 可能已经悬空——这是主线 4 的那条横线。

9. 取结果。 W0 的 wait_until 返回,job_b.into_result() 取出 JobResult:Ok(x) 返回 x,Panic(p) 用 resume_unwind 重新抛出。

migrated:B 知不知道自己被偷了

join_context 的两个闭包收到一个 FnContext,migrated() 告诉闭包「我是不是跑在别的线程上」:

migrated() 的值来源
Ainjectedin_worker 的第二个参数:整个 join 是不是从池外注入进来的
B,被本线程弹回injectedrun_inline(injected)
B,被偷trueJobResult::call 固定传 true

rayon 的迭代器靠这个值调整切分:被偷说明有空闲线程,值得多切几刀。从 main 直接调用时 injected 为真,于是最外层两个子任务都「被迁移」,这会让切分树多出一层,见 一次 par_iter。

A panic 了怎么办

A panic 时不能立刻把 panic 抛出去:B 可能正在别的线程上运行,而 B 可能借用了这个栈帧上的数据。join_recover_from_panic 先 wait_until(job_b_latch),等 B 结束,再 resume_unwinding(err)。如果 B 还在本地 deque 里没被偷,wait_until 期间会从本地把它弹出来执行——也就是 A panic 之后 B 照样会跑完。两边都 panic 时传播的是 A 的 panic(join 的文档如此,E11 实测一致,见 join 与 join_context)。

每一步在哪一章

步本卷
in_worker 三条路径、冷启动的 LockLatch、跨池in_worker:从哪进池
wait_until_cold、find_work、steal 的随机起点WorkerThread 主循环
JobRef、StackJob、JobResultJobRef 与几种 Job
CoreLatch 四态、SpinLatch::set 的读后置位Latch 家族
migrated、为什么先推 B、粒度曲线 E1join 与 join_context
new_internal_jobs 叫醒谁、睡着的 W0 怎样被唤醒05 sleep 模块
deque 的 push / pop / steal 本身crossbeam:一个任务

小结

  • join 零堆分配:B 是栈上的 StackJob,进 deque 的是 16 字节的 JobRef。
  • 先推 B、再跑 A、再找 B。B 没被偷时自己 run_inline,全程不碰 latch,也没有跨线程同步。
  • B 被偷时,原线程在 wait_until 里边等边找活,不阻塞;窃取者先写结果、后 set latch,set 时只在原线程已睡着时才唤醒它。
  • migrated() 告诉闭包是否被偷或被注入,迭代器用它调整切分。
  • A panic 也要等 B 完成才传播,因为 B 可能借用了栈上的数据。

💬 关联讨论

发起讨论
加载中...