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 的包装
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:逐行
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
/// 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:
| cutoff | join 次数 | 12 线程加速比(三次) | 8 线程 | 1 线程 |
|---|---|---|---|---|
| 1 | 24 157 816 | 1.79–1.86 | 1.52 | 0.20 |
| 5 | 3 524 577 | 6.08–6.44 | 5.19 | 0.69 |
| 10 | 317 810 | 9.07–9.96 | 7.50 | 1.00 |
| 15 | 28 656 | 9.78–10.18 | 7.49 | 1.00 |
| 20 | 2 583 | 9.83–10.13 | 7.39 | 1.00 |
| 25 | 232 | 9.35–9.83 | 7.03 | 0.96 |
| 30 | 20 | 7.97–8.25 | 6.45 | 0.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-* 模式把时序固定下来:
- 2 线程的池。worker W 上:加锁,然后
join(A, B);A 自旋直到 B 开始执行,所以 B 一定被另一个 worker 偷走;B 开始后睡 100 ms,占住那个 worker。 - 池外一个线程看到 B 开始后,
pool.spawn(Y),Y 进注入队列。 - W 跑完 A,本地 deque 是空的,进
wait_until:另一个 worker 的 deque 也是空的(B 正在执行),注入队列里有 Y,于是 W 执行 Y。
| 模式 | Y 做什么 | 结果 |
|---|---|---|
determ-try | try_lock() | 20/20 次:Y 跑在 W 上,W 的外层栈帧持有锁,try_lock 返回 WouldBlock |
determ-lock | lock() | 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)。