Rust in Everything
基于 rayon-core 1.13.0

join 与 join_context

主线位置:一次 join 的全部。join(a, b) → join_context → registry::in_worker 拿到当前 worker → 栈上建 B 的 StackJob → push 进本地 deque → 跑 A → 循环弹本地任务:是 B 就内联跑,不是就执行它,本地空了就 wait_until(B 的 latch) → 返回 (ra, rb)。

sequenceDiagram
  participant W as 调用线程 W(worker)
  participant D as W 的本地 deque
  participant T as 窃取者 T
  W->>D: push(B 的 JobRef),可能叫醒一个睡着的线程
  W->>W: halt_unwinding(A)
  alt B 没被偷
    W->>D: take_local_job()
    D-->>W: 正是 B(id 相同)
    W->>W: run_inline(B),直接返回
  else B 被偷
    T->>D: steal(),拿到 B
    T->>T: execute:跑 B,写 result
    W->>D: take_local_job(),空
    W->>W: wait_until(B.latch):找别的活,没活就睡
    T->>W: Latch::set,W 若在睡就叫醒
    W->>W: into_result(B)
  end

join 是 rayon 的唯一并行原语:scope 和 spawn 用的是另一套(HeapJob + 计数),但并行迭代器的每一次切分都是一次 join_context(见 一次 par_iter)。代码在 join/mod.rs,去掉文档注释不到一百行。

join 只是 join_context 的包装

join/mod.rs 第 93–106 行:

pub fn join<A, B, RA, RB>(oper_a: A, oper_b: B) -> (RA, RB)
where
    A: FnOnce() -> RA + Send,
    B: FnOnce() -> RB + Send,
    RA: Send,
    RB: Send,
{
    #[inline]
    fn call<R>(f: impl FnOnce() -> R) -> impl FnOnce(FnContext) -> R {
        move |_| f()
    }

    join_context(call(oper_a), call(oper_b))
}

四个 Send 约束都是必需的:B 可能被别的线程偷走执行,结果从那个线程送回来;A 虽然总在「当前 worker」上跑,但如果调用线程不在池里,当前 worker 就是池里的某个线程,A 也换了线程。join 本身是同步的:两边都完成才返回,所以 A、B 可以借用调用方栈上的数据,不需要 'static。

join_context:逐行

join/mod.rs 第 115–173 行:

pub fn join_context<A, B, RA, RB>(oper_a: A, oper_b: B) -> (RA, RB)
where
    A: FnOnce(FnContext) -> RA + Send,
    B: FnOnce(FnContext) -> RB + Send,
    RA: Send,
    RB: Send,
{
    #[inline]
    fn call_a<R>(f: impl FnOnce(FnContext) -> R, injected: bool) -> impl FnOnce() -> R {
        move || f(FnContext::new(injected))
    }

    #[inline]
    fn call_b<R>(f: impl FnOnce(FnContext) -> R) -> impl FnOnce(bool) -> R {
        move |migrated| f(FnContext::new(migrated))
    }

    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())
    })
}

第 0 步:拿到一个 worker

registry::in_worker:当前线程是任何一个池的 worker,就直接 op(&*worker_thread, false);否则进全局池,走 in_worker_cold(注入一个 StackJob,调用线程在线程本地的 LockLatch 上阻塞),op 在池里的某个 worker 上以 injected = true 执行。所以从 main 调一次 join,固定成本是「注入 + 唤醒一个睡着的线程 + 调用线程睡下再被叫醒」;在池内调,只是几次 deque 操作。三条路径的细节见 in_worker:从哪进池;两者的开销差距在 唤醒策略与实测 里量。

注意是「任何一个池」:全局函数 rayon::join 在哪个池的 worker 上被调用,就在哪个池里并行。

第 1 步:B 进 deque,A 在本线程跑

B 包进 StackJob,latch 是绑定当前 worker 的 SpinLatch,整个 StackJob 就在 join_context 的栈帧上,不分配堆内存。push 进本地 deque 的同时调 sleep.new_internal_jobs(1, queue_was_empty):有线程在睡、空闲的线程又不够时,会叫醒一个来偷(决策规则见 唤醒策略与实测)。

然后当前线程立刻跑 A。为什么是「推 B、跑 A」而不是反过来:

  • A 在当前线程上马上开始,用的是调用方刚刚碰过的数据,缓存是热的,也不需要任何同步。
  • 窃取者从 deque 的另一端偷,拿到的是最老的任务。在递归分治里,最老的 B 是最外层 join 推进去的那个,对应最大的一块工作;所以别的线程一偷就偷走一大块,而当前线程继续在 A 里往下切、往 deque 里推越来越小的 B。deque 的 LIFO / FIFO 两端见 crossbeam 卷的 Chase-Lev 一章。

halt_unwinding 把 A 的 panic 接住,先不传播,原因见下面「A panic 时」。

第 2 步:把 B 找回来

A 跑完后,循环的条件是 !job_b.latch.probe():B 已经被别人执行完,latch 已设置,直接去取结果。否则弹本地任务:

  • 弹出来的正是 B(job_b_id == job.id(),见 JobRef 与几种 Job 的 id() 一节):run_inline(injected),把闭包当普通函数调用,结果直接返回,不写 result、不碰 latch。这是没有窃取时的快路径,B 的 panic 直接在当前栈上展开。
  • 弹出来的不是 B:B 上面还压着别的任务。A 跑完时,A 内部嵌套的 join 都已经各自收回了自己的 B,所以压在上面的只能是 A 推进来又没等的任务,比如 A 里调的 rayon::spawn,或者对外层某个 scope 调的 scope.spawn(两者都 inject_or_push,在 worker 上就推本地 deque;A 内部自己开的 scope 会在返回前等完自己的任务)。take_local_job 在本地 deque 为空时还会去取本线程的广播队列,所以也可能是一个广播任务。这些都 execute 掉,继续弹。
  • 本地空了:B 被偷了(也可能被 A 里调的 yield_local 之类的东西执行掉了)。调 wait_until(&job_b.latch):先看本地、再去偷别人、再看注入队列,找到什么就执行什么,都找不到就按 sleep 模块的协议睡下,等窃取者执行完 B 后 Latch::set 叫醒(见 WorkerThread 主循环 与 睡眠与唤醒的握手)。

wait_until 里执行的任务可能是任何东西:别的线程 deque 里的任务、注入队列里来自池外的任务。它们都在当前线程的栈上、join_context 这一帧的上方执行。这是「等待时继续干活」的代价,本页最后一节用实验看它的后果。

migrated 的取值

A 收到的 FnContext::migrated() 是 injected;B 被内联时也是 injected,被 execute 时一律是 true(JobResult::call 写死的)。所以从池外调用时 A、B 都报 true,即使 B 被同一个线程弹回。JobRef 与几种 Job 里的实验 E14 在 1 线程的池里验证了这几种组合。并行迭代器的切分器拿这个值决定要不要多切(bridge 与自适应切分)。

A panic 时:先等 B

join/mod.rs 第 175–186 行:

/// If job A panics, we still cannot return until we are sure that job
/// B is complete. This is because it may contain references into the
/// enclosing stack frame(s).
#[cold] // cold path
unsafe fn join_recover_from_panic(
    worker_thread: &WorkerThread,
    job_b_latch: &SpinLatch<'_>,
    err: Box<dyn Any + Send>,
) -> ! {
    worker_thread.wait_until(job_b_latch);
    unwind::resume_unwinding(err)
}

B 的 StackJob 在这一帧上,B 的闭包也可能借用这一帧以下的数据。A panic 后如果直接展开,B 还在 deque 里或正在别的线程上跑,就会访问已经被销毁的栈。所以先 wait_until 等 B 完成。如果 B 还在本地 deque 里,wait_until 第一件事就是弹本地任务,B 会在这里被 execute(而不是内联),它若也 panic,panic 被装进 JobResult,随 job_b 一起丢弃;最后传播的是 A 的。

文档的说法(第 86–92 行):两个闭包一定都会执行;只有一个 panic 就传播那一个;两个都 panic 传播第一个闭包的。

实验 E11(exp-rayon/panics/):

模式结果
A、B 都 panic传播 "A",5 次都是
A 立即 panic,B 睡 50 ms 后置标志传播 "A";展开时 B 的标志已置位(B 确实跑完了)
B panic,A 睡 50 ms 后置标志传播 "B";展开时 A 的标志已置位(join 先等 A 跑完,再在 into_result 里 resume_unwind)

实测:join 的固定开销(E1)

exp-rayon/join-granularity/:fib(36),n <= cutoff 时转串行,扫 cutoff。每组 5 次取中位数,计时放在 rayon::scope 里(只有最外层的一次进池走注入路径),开始前 broadcast 一次让所有线程都起来。join 的次数按递归精确计算(cutoff 1 时 24 157 816 次)。12 线程跑三次,8 线程、1 线程各一次;开始前 1 / 5 分钟负载 1.13 / 2.94,之后几次 1.69 / 2.96:

cutoffjoin 次数12 线程加速比(三次)8 线程1 线程
124 157 8161.79–1.861.520.20
53 524 5776.08–6.445.190.69
10317 8109.07–9.967.501.00
1528 6569.78–10.187.491.00
202 5839.83–10.137.391.00
252329.35–9.837.030.96
30207.97–8.256.450.94

串行基线 40.8–42.1 ms。几点:

  • 1 线程是反向验证:没有别的线程可偷,cutoff 10–20 时并行版和串行版持平(1.00);cutoff 1 时 199.8 ms,比串行多 159 ms,摊到 2416 万次 join 上每次约 6.6 ns,cutoff 5 时算出来约 5.2 ns。这就是一次 push + pop + id 比较 + catch_unwind + 内联调用的成本,不含任何窃取和唤醒。(规划阶段按近似公式算的 join 次数是 1490 万,得出「约 10 ns」,次数算少了。)
  • 12 线程最好约 10 倍,在 cutoff 15–20(几千到几万次 join、每个叶子几十微秒);cutoff 5 时叶子太小,只有 6 倍;cutoff 1 时只有 1.8 倍。cutoff 30 只有 20 次 join、21 个大小悬殊的叶子,不够 12 个线程均分,掉到 8 倍。
  • 8 线程最好 7.5 倍。12 个核里 4 个是能效核,12 线程比 8 线程多出约 2.5 倍,量级上像是「4 个能效核约等于 2–3 个性能核」(推测,macOS 不能绑核,没法单独测能效核)。

并行迭代器里叶子多大由切分器决定(bridge 与自适应切分),用户能调的是 with_min_len / with_max_len。

等待时干别的活:持锁调 rayon(E7)

join 的文档在第 76–84 行警告过阻塞 IO 和「一个闭包等另一个闭包」会导致死锁。还有一种更隐蔽的情况:持有 std::sync::Mutex 时调用 rayon。

当前线程持锁进入 join,B 被偷,当前线程进 wait_until,从本地 deque、别人的 deque 或注入队列里拿到一个任务,在同一个线程上执行。如果那个任务也要这把锁,std::sync::Mutex 不可重入,这个线程就在等它自己。

确定性复现

exp-rayon/lock-reentry/ 的 determ-* 模式把时序固定下来:

  1. 2 线程的池。worker W 上:加锁,然后 join(A, B);A 自旋直到 B 开始执行,所以 B 一定被另一个 worker 偷走;B 开始后睡 100 ms,占住那个 worker。
  2. 池外一个线程看到 B 开始后,pool.spawn(Y),Y 进注入队列。
  3. W 跑完 A,本地 deque 是空的,进 wait_until:另一个 worker 的 deque 也是空的(B 正在执行),注入队列里有 Y,于是 W 执行 Y。
模式Y 做什么结果
determ-trytry_lock()20/20 次:Y 跑在 W 上,W 的外层栈帧持有锁,try_lock 返回 WouldBlock
determ-locklock()5/5 次:10 秒看门狗超时,进程卡死
determ-free(反向)try_lock(),但 W 在 join 前先放锁3/3 次:Y 仍然跑在 W 上,try_lock 成功

W 拿到 Y 的路径是 find_work 的最后一步 pop_injected_job。真实程序里更常见的来源是本地 deque:并行迭代器的外层切分把一串外层任务的 B 压在当前线程的 deque 里,内层 par_iter 等待时第一件事就是弹本地任务,弹到的正是外层的下一个元素。

概率性复现

survey 程序的写法:外层 200 个元素 par_iter().for_each,每个元素里调一次内层 (0..2000).into_par_iter().map(重计算).sum(),12 线程,10 秒看门狗。持锁的模式在加锁前检查「当前线程是否已在外层栈帧里持有这把锁」,是就打印一行 same-thread relock。下表是 1 / 5 分钟负载 2.31 / 2.79 时的一轮(规划期和高负载时另跑过几组,死锁比例在 40%–60%(hold-some)和 10%–30%(hold-all)之间,同一量级):

模式结果
detect:不加锁,用线程本地深度计数统计「一个外层元素在另一个外层元素的栈帧上开始执行」三次分别 61、55、47 次。不加锁时这是正常现象,说明外层任务确实会嵌套在别的外层任务里
hold-some:每 8 个外层元素有 1 个持锁调内层10 次里 7 次死锁
hold-all:每个外层元素都持锁10 次里 2 次死锁
release-first:先算内层再加锁10/10 正常结束

9 次死锁每次都先打印了 same-thread relock,11 次正常结束的持锁运行一次都没有:死锁就是同线程重入,不是别的原因。规划阶段计划把 hold-all 当作「不会死锁」的反例,实测它也会死锁,只是概率低一些。

hold-all 也会死锁:持锁线程等内层任务时,别的线程大多阻塞在 lock() 上,但总有线程处在「刚做完一个元素、还没拿下一个」的空档里,会偷走内层任务;持锁线程一进 wait_until,弹本地 deque 就可能弹到外层的下一个元素。

改法:不要在持锁期间调用任何可能进入 rayon 的代码(join、scope、par_iter、install);先把并行部分算完再加锁,或者把共享状态改成每个任务各自累加、最后归约。持有 RefCell 借用、依赖 thread_local 的「当前上下文」也有同样的问题:等待期间同一个线程会开始执行无关的任务。

小结

  • join 先推 B、再在本线程跑 A:A 用热缓存,窃取者拿走最老也最大的那块工作。
  • A 跑完后弹本地任务:是 B 就内联执行(不经 latch),不是就执行掉,本地空了就 wait_until(B 的 latch),边等边找活,找不到就睡。
  • A panic 也要等 B 完成才展开;两边都 panic 传播 A 的,只有 B panic 时先等 A 跑完(E11)。
  • 1 线程下每次 join 约 6.6 ns;fib(36) 在 12 线程上最好约 10 倍,阈值太小只有 1.8 倍(E1)。
  • 等待时执行的任务跑在当前线程的栈上:持锁调 rayon,偷来的任务再加同一把锁就会死锁,确定性复现 20/20,普通写法 10 次里 7 次(E7)。

💬 关联讨论

发起讨论
加载中...