一次 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() 的值 | 来源 | |
|---|---|---|
| A | injected | in_worker 的第二个参数:整个 join 是不是从池外注入进来的 |
| B,被本线程弹回 | injected | run_inline(injected) |
| B,被偷 | true | JobResult::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、JobResult | JobRef 与几种 Job |
CoreLatch 四态、SpinLatch::set 的读后置位 | Latch 家族 |
migrated、为什么先推 B、粒度曲线 E1 | join 与 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 可能借用了栈上的数据。