Rust in Everything
基于 rayon-core 1.13.0

最小 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 次:

cutoffjoin 次数2 线程4 线程12 线程
2913/3 完成—3/3 完成
2823/3 完成—3/3 完成
274卡死2/2 完成3/3 完成
267卡死2/2 完成3/3 完成
2512卡死1 次完成、1 次卡死3/3 完成
2420——3/3 完成
2254——3/3 卡死
10177103/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 家族)
跨池的 latchSpinLatch::cross,set 前 Arc::clone registry
全局函数 joinrayon 的 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 但不偷不会死锁,只是变慢。

💬 关联讨论

发起讨论
加载中...