最小 join
主线位置:主线 1「一次 join」的缩小版,对照 join 与 join_context;栈上 job 和「set 之后不碰」对照 JobRef 与几种 Job、Latch 家族。代码在
exp-rayon/hands-on/src/join.rs和src/pool.rs。
flowchart TB
start["join_context(a, b)"] --> iw{"in_worker:本池 worker?"}
iw -->|否| cold["注入整个闭包,LockLatch 上等"]
cold --> wk
iw -->|是| wk["栈上建 job_b(SpinLatch),push 进本地 deque"]
wk --> ra["跑 A"]
ra --> pop{"本地 pop"}
pop -->|"正是 B"| inline["run_inline:不经 latch"]
pop -->|"别的任务"| exec["执行它"]
exec --> pop
pop -->|"空:B 被偷了"| wait["wait_until(B 的 latch):找活干,没活就睡"]
wait --> res["读 B 的结果"]
inline --> ret["返回 (ra, rb)"]
res --> ret
栈上的 job
pub(crate) struct StackJob<L, F, R> {
pub(crate) latch: L,
func: UnsafeCell<Option<F>>,
result: UnsafeCell<Option<R>>,
}
impl<L: Latch, F: FnOnce(bool) -> R, R> Job for StackJob<L, F, R> {
unsafe fn execute(this: *const ()) {
let this = this as *const Self;
let func = match unsafe { (*(*this).func.get()).take() } {
Some(func) => func,
None => unreachable!("job executed twice"),
};
let abort = AbortIfPanic;
// Reached through a JobRef: stolen or injected, so "migrated".
let r = func(true);
mem::forget(abort);
unsafe { *(*this).result.get() = Some(r) };
// Last touch of `*this`: after set() the owner may pop the frame.
unsafe { L::set(&(*this).latch) };
}
}
和 rayon 的 StackJob::execute 一样:取闭包、执行、写结果、最后 set latch。经 JobRef 执行的一定是被偷或被注入的,所以传 true;被主人弹回来时走 run_inline(injected),不经过这里。
latch:set 的参数是裸指针
pub(crate) trait Latch {
/// Takes a raw pointer, not `&self`: once the flag is set the waiter may
/// return and free the latch, so `set` must not touch it afterwards.
unsafe fn set(this: *const Self);
}
impl Latch for SpinLatch<'_> {
unsafe fn set(this: *const Self) {
// Read everything we need first. `Shared` itself stays alive: the setter
// is a worker of the same pool and holds an Arc to it.
let shared: &Shared = unsafe { (*this).shared };
unsafe { (*this).done.store(true, Ordering::Release) };
// From here on `this` may dangle.
shared.sleep.wake_all();
}
}
SpinLatch 就是一个 AtomicBool 加一个指向池的引用。store(true) 之后,等待的线程可能马上看到它、从 join 返回、弹掉整个栈帧,latch 跟着没了;所以 wake_all 要用的 &Shared 必须在 store 之前读出来。这正是 rayon 的 Latch::set 取 *const Self 而不是 &self 的原因,SpinLatch::set 也是先读出 registry 和 target_worker_index 再 set(Latch 家族)。Shared 本身不会被释放:执行 set 的线程是同一个池的 worker,它的 WorkerThread 持有 Arc<Shared>。rayon 跨池时没有这个保证,所以 cross 变体要先 Arc::clone 一份 registry;这里不支持跨池。
唤醒用 wake_all:不知道等待者是哪个线程,就把睡着的都叫起来,各自复查。rayon 的 SpinLatch 记下了等待者的线程号,只在 CoreLatch::set 发现对方已经睡下时点名叫它一个。
join_context
fn join_in_worker<A, B, RA, RB>(wt: &WorkerThread, injected: bool, a: A, b: B) -> (RA, RB)
where
A: FnOnce(bool) -> RA + Send,
B: FnOnce(bool) -> RB + Send,
RA: Send,
RB: Send,
{
let job_b = StackJob::new(SpinLatch::new(&wt.shared), b);
let job_b_ref = unsafe { JobRef::new(&job_b) };
wt.push(job_b_ref);
// If A panics, B may be running on another thread and still point into
// this frame. rayon waits for B and then resumes the panic; we abort.
let guard = AbortIfPanic;
let ra = a(injected);
#[cfg(not(feature = "block_wait"))]
while !job_b.latch.probe() {
match wt.pop() {
Some(job) if job == job_b_ref => {
// Nobody stole B: run it here, no latch involved.
mem::forget(guard);
let rb = job_b.run_inline(injected);
return (ra, rb);
}
// Something A left behind (can't happen with join alone, can with spawn).
Some(job) => unsafe { wt.execute(job) },
None => {
// B was stolen: wait, executing other jobs meanwhile.
wt.wait_until(&job_b.latch);
break;
}
}
}
#[cfg(feature = "block_wait")]
wt.block_until(&job_b.latch);
mem::forget(guard);
(ra, job_b.into_result())
}
逐行对得上 rayon 的 join_context。JobRef 派生了 PartialEq,比较的就是 (pointer, execute_fn),等价于 rayon 的 job.id()。Pool::join_context(a, b) 外面再套一层 最小线程池 的 in_worker:池外调用时整个闭包被注入,A 拿到的 injected 是 true。
等待时干活的 wait_until:
pub(crate) fn wait_until(&self, latch: &SpinLatch<'_>) {
while !latch.probe() {
if let Some(job) = self.find_work() {
unsafe { job.execute() };
continue;
}
let shared = &self.shared;
shared.sleep.sleep(|| !latch.probe() && !shared.has_work());
}
}
(源码里这个函数还有一个 no_steal_wait 的 cfg 分支,下面的反向验证用。)找不到活就按上一页的握手去睡,复查条件里多了「latch 还没置位」;SpinLatch::set 那边是「store、fence、读 sleepers、wake_all」,同样的 store-buffering 配对。rayon 的 wait_until_cold 多了一层:执行完一个任务后先回头看本地 deque,并经 sleep 模块的 32 轮空转才睡(WorkerThread 主循环)。
实测:fib
src/bin/fib.rs:fib(n),n <= cutoff 时转串行,否则 pool.join(fib(n-1), fib(n-2));整个递归放在 pool.install 里,只有根经冷路径进池。和 E1 用同一个 fib(36),每次都和串行结果核对(assert_eq!),每组 5 次取中位数。tests/mini.rs 在 1 / 2 / 4 / 12 线程上分别从池外和池内跑 fib(20)、fib(25),结果都对。
TIMING_TABLE
反向验证一:直接阻塞在 latch 上
--features block_wait:A 跑完后既不弹 B、也不找别的活,直接在 B 的 latch 上睡(block_until:只复查 latch)。fib(30),5 秒看门狗,每格 1–3 次:
| cutoff | join 次数 | 2 线程 | 4 线程 | 12 线程 |
|---|---|---|---|---|
| 29 | 1 | 3/3 完成 | — | 3/3 完成 |
| 28 | 2 | 3/3 完成 | — | 3/3 完成 |
| 27 | 4 | 卡死 | 2/2 完成 | 3/3 完成 |
| 26 | 7 | 卡死 | 2/2 完成 | 3/3 完成 |
| 25 | 12 | 卡死 | 1 次完成、1 次卡死 | 3/3 完成 |
| 24 | 20 | — | — | 3/3 完成 |
| 22 | 54 | — | — | 3/3 卡死 |
| 10 | 17710 | 3/3 卡死 | 3/3 卡死 | 3/3 卡死 |
1 线程时第一次 join 就卡死:B 在自己的 deque 里,自己又不去弹。多线程时,每个阻塞的线程手里都压着一个没人弹的 B,只能等别的线程来偷(偷的是 deque 里最老的那个);嵌套的 join 一多,阻塞的线程凑满整个池,剩下的 B 就永远躺在各自的 deque 里。线程越多能撑越深,但嵌套 join 的数量随 n 指数增长,迟早凑满。
反向验证二:弹回 B,但不偷
--features no_steal_wait:弹回 B 的那段照旧,只是 B 被偷之后不再找活,直接阻塞在 latch 上。这样不会死锁:B 被偷时本地 deque 已经空了(偷总是从最老的一端拿,B 被偷说明比它老的也都被偷了),阻塞的线程手里没有压着别人要的任务;它等的 B 在一个正在运行的线程上,那个线程迟早做完。代价是并行度:
NOSTEAL_TABLE
两个反向版本合起来说明了 wait_until 里两件事各管什么:把 B 弹回来(或者有人能执行它)关乎正确性,没有它会死锁;等的时候去偷别人的活关乎吞吐,没有它线程空等,线程越多空等越多。rayon 选了后者,也就接受了它的代价:等待中的线程会在当前栈帧上执行毫不相干的任务,持锁调 rayon 会因此死锁(join 与 join_context 的 E7)。
(这两个阻塞版本的睡眠者不会去找活,wake_one 的一次 notify_one 可能落在它们身上而被浪费,所以这两个 feature 下 wake_one 改成 notify_all。正常版和 no_recheck 版仍是 notify_one。)
省掉了什么
| 省掉的 | rayon 怎么做 |
|---|---|
| A panic 时等 B 再传播 | halt_unwinding(A),出错就 join_recover_from_panic:先 wait_until(B),再 resume_unwinding(join 与 join_context);B 的 panic 存进 JobResult::Panic。这里任何 panic 都 abort |
| 点名唤醒 | CoreLatch 四态(UNSET / SLEEPY / SLEEPING / SET),set 时只有对方真睡着才叫它一个(Latch 家族) |
| 跨池的 latch | SpinLatch::cross,set 前 Arc::clone registry |
全局函数 join | rayon 的 join 不带池参数,按调用线程所在的池(不在池里就用全局池);这里只有 Pool::join |
FnContext | 这里直接把 bool 传给闭包 |
小结
- B 是栈上的
StackJob,经 16 字节的JobRef进本地 deque;A 在本线程跑;回头弹本地:是 B 就内联,不经 latch。 - B 被偷时
wait_until:边找活边等,没活按池的握手去睡,复查里加上「latch 是否已置位」。 Latch::set取裸指针:先读出要用的&Shared,再store(true),之后不再碰 latch。- 反向验证:直接阻塞在 latch 上,嵌套
join一多就卡死(2 线程 4 次join、12 线程 54 次join即卡死);弹回 B 但不偷不会死锁,只是变慢。