Rust in Everything
基于 rayon-core 1.13.0

最小工作窃取线程池

主线位置:主线 3「一个线程睡下又醒来」的缩小版,外加 WorkerThread 主循环 的找活顺序和 in_worker 的冷路径。代码在 exp-rayon/hands-on/src/pool.rs。

flowchart TB
  loopTop["主循环"] --> fw{"find_work"}
  fw -->|"本地 pop"| run["执行"]
  fw -->|"随机起点偷别人"| run
  fw -->|"注入队列"| run
  run --> loopTop
  fw -->|"都没有"| term{"terminate?"}
  term -->|是| quit["退出线程"]
  term -->|否| reg["加锁,sleepers += 1,SeqCst fence"]
  reg --> re{"复查:队列非空或 terminate?"}
  re -->|是| back["sleepers -= 1,不睡"]
  back --> loopTop
  re -->|否| wait["Condvar::wait"]
  wait --> loopTop

结构:一个 Shared,每线程一个 WorkerThread

pub(crate) struct Shared {
  injector: Injector<JobRef>,
  stealers: Vec<Stealer<JobRef>>,
  pub(crate) sleep: Sleep,
  terminate: AtomicBool,
}

pub(crate) struct WorkerThread {
  worker: Worker<JobRef>,
  index: usize,
  pub(crate) shared: Arc<Shared>,
  rng: Cell<u64>,
}

thread_local! {
  static CURRENT: Cell<*const WorkerThread> = const { Cell::new(ptr::null()) };
}

和 rayon 的 Registry / WorkerThread 是同一个形状:Pool::new 先建好 n 个 LIFO 的 Worker,把它们的 Stealer 收进 Shared,再把每个 Worker 移进自己的线程。Worker 不是 Sync,只有主人线程碰它(crossbeam 卷:Worker 不是 Sync);线程本地的 CURRENT 指向线程栈上的 WorkerThread,「我是不是这个池的 worker」就靠它判断。

任务是 rayon 那样的手写胖指针 JobRef { pointer, execute_fn },16 字节,指向别处(通常是某个栈帧)的一个 job(JobRef 与几种 Job)。这一页只用到它的「执行一次」,栈上 job 和 latch 在 最小 join 里讲。

找活:本地、偷、注入

fn find_work(&self) -> Option<JobRef> {
  self.worker.pop().or_else(|| self.steal()).or_else(|| loop {
    match self.shared.injector.steal() {
      Steal::Success(job) => return Some(job),
      Steal::Empty => return None,
      Steal::Retry => {}
    }
  })
}

fn steal(&self) -> Option<JobRef> {
  let n = self.shared.stealers.len();
  if n <= 1 {
    return None;
  }
  loop {
    let mut retry = false;
    let start = (self.next_random() % n as u64) as usize;
    for i in (start..n).chain(0..start) {
      if i == self.index {
        continue;
      }
      match self.shared.stealers[i].steal() {
        Steal::Success(job) => return Some(job),
        Steal::Retry => retry = true,
        Steal::Empty => {}
      }
    }
    if !retry {
      return None;
    }
  }
}

顺序和 rayon 的 find_work 一样(rayon 在本地 deque 之后还多看一个本线程的广播队列)。steal 是 registry.rs:873-904 的简化:随机起点轮一圈,遇到过 Retry 就整轮重来,只有一圈下来全是 Empty 才说没有(crossbeam 卷:把 Retry 当成 Empty)。

睡眠:登记、复查、再等

整个池一把 Mutex<()>、一个 Condvar、一个 sleepers 计数:

pub(crate) fn sleep(&self, still_idle: impl Fn() -> bool) {
  let guard = lock(&self.lock);
  self.sleepers.fetch_add(1, Ordering::SeqCst);
  // Pairs with the fence in wake_*: either the waker sees sleepers > 0,
  // or we see its job / latch in the re-check below.
  fence(Ordering::SeqCst);
  #[cfg(not(feature = "no_recheck"))]
  if !still_idle() {
    self.sleepers.fetch_sub(1, Ordering::SeqCst);
    return;
  }
  #[cfg(feature = "no_recheck")]
  let _ = &still_idle;
  let guard = self.cv.wait(guard).unwrap_or_else(|e| e.into_inner());
  self.sleepers.fetch_sub(1, Ordering::SeqCst);
  drop(guard);
}

pub(crate) fn wake_one(&self) {
  fence(Ordering::SeqCst);
  if self.sleepers.load(Ordering::SeqCst) > 0 {
    // Taking the lock means a sleeper is either not registered yet
    // (and will re-check) or already inside cv.wait.
    let _guard = lock(&self.lock);
    // The blocking reverse variants have sleepers that never look for
    // work; a single notify could land on one of them, so wake everyone.
    if cfg!(any(feature = "block_wait", feature = "no_steal_wait")) {
      self.cv.notify_all();
    } else {
      self.cv.notify_one();
    }
  }
}

主循环里的复查条件是「terminate 没置位,而且注入队列和每个线程的 deque 都是空的」。每次 push 进本地 deque、每次 inject 之后都调 wake_one。

为什么这样就不丢唤醒,是两个配对:

  1. 登记和复查之间的 fence 对上唤醒方的 fence。 睡的一方「写 sleepers,fence,读队列」,唤醒方「写队列,fence,读 sleepers」,是 store-buffering 形状,两边都有 SeqCst fence,所以两边不可能都读到旧值:要么唤醒方看到 sleepers > 0,要么睡的一方复查时看到新任务。crossbeam 的队列内部用的是 Release / Acquire,单靠它们挡不住这种形状,所以两道 fence 都要自己加。
  2. 唤醒方要先拿同一把锁再 notify。 睡的一方从登记到进入 Condvar::wait 一直持锁,所以唤醒方拿到锁时,对方要么还没登记(那它一定会复查到任务),要么已经在 wait 里(notify_one 一定叫得到)。

rayon 的 sleep 是同样的两条,只是更省:锁是每线程一把 is_blocked,「登记」是对打包计数器的 CAS,复查前那道 fence 在 第 170 行,配对的是 new_injected_jobs 里的 fence;而且 rayon 的最后一次复查只看注入队列和本线程广播队列,本地 push 的任务允许被漏看,因为推任务的线程自己迟早会弹出来执行(睡眠与唤醒的握手)。这里的复查连所有 deque 一起看,多花一点,换来不用论证「漏看内部任务没关系」。

进池:注入后在 LockLatch 上等

pub(crate) fn in_worker<OP, R>(&self, op: OP) -> R
where
  OP: FnOnce(&WorkerThread, bool) -> R + Send,
  R: Send,
{
  let wt = WorkerThread::current();
  if !wt.is_null() {
    let wt = unsafe { &*wt };
    if Arc::ptr_eq(&wt.shared, &self.shared) {
      return op(wt, false);
    }
  }
  let job = StackJob::new(LockLatch::new(), |injected| {
    let wt = WorkerThread::current();
    if wt.is_null() {
      unreachable!("injected job runs on a worker thread");
    }
    op(unsafe { &*wt }, injected)
  });
  self.shared.inject(unsafe { JobRef::new(&job) });
  job.latch.wait();
  job.into_result()
}

就是 rayon 的 in_worker 和 in_worker_cold:已经在本池的 worker 上就直接调用(injected = false);否则把闭包包成栈上的 job 注入,调用线程睡在 Mutex<bool> + Condvar 的 LockLatch 上。job 经 JobRef 执行时传给闭包的是 true,这个 injected 在 最小 Splitter 里会用到。Pool::install(op) 就是 in_worker(|_, _| op())。

测试与反向验证

src/bin/lost_wakeup.rs:2 个 worker,4 个池外线程各自循环 20 万次 pool.install(|| i * 2),即「注入一个极小的任务、在 LockLatch 上等它」。池子大部分时间是空的,worker 不停地在「找不到活、去睡」和「被叫醒」之间切换,正好反复撞睡眠的握手。另有一个看门狗线程,10 秒内没跑完就打印已完成的次数并以退出码 3 退出。

正常构建连跑 10 次,10 次都是 ok: 800000 installs,每次约 2.8 秒。tests/mini.rs 里还有 3 线程池、4 个池外线程各 2 万次 install 的测试,以及 1 / 2 / 4 / 12 线程上从池外和池内跑 fib 的测试,都通过。

反向验证:--features no_recheck,登记之后不复查,直接 wait,其余不动:

STUCK: 2653 installs finished, then nothing for the rest of 10 s
STUCK: 136 installs finished, then nothing for the rest of 10 s
STUCK: 869 installs finished, then nothing for the rest of 10 s
STUCK: 312 installs finished, then nothing for the rest of 10 s
STUCK: 9929 installs finished, then nothing for the rest of 10 s
STUCK: 10825 installs finished, then nothing for the rest of 10 s
STUCK: 8144 installs finished, then nothing for the rest of 10 s
STUCK: 1671 installs finished, then nothing for the rest of 10 s
STUCK: 6656 installs finished, then nothing for the rest of 10 s
STUCK: 1014 installs finished, then nothing for the rest of 10 s

10 次 10 次卡死,最早的一次在第 136 次 install 时。1 个 worker、1 个池外线程的形状也是 3 次 3 次卡死(分别在第 88657、19783、180298 次)。卡住时的状态:一个任务躺在注入队列里,所有 worker 都在 Condvar 上,池外线程睡在 LockLatch 上,没有谁会再去叫醒谁。丢唤醒的那一刻是:

sequenceDiagram
  participant W as worker
  participant Q as 注入队列
  participant S as sleepers
  participant M as 池外线程
  W->>Q: find_work:空
  M->>Q: push(job)
  M->>S: fence,读到 0:没人睡,不 notify
  W->>S: 加锁,sleepers = 1
  Note over W: 没有复查
  W->>W: Condvar::wait,永远不醒

有复查时,最后一步之前 worker 会看到队列里的 job,退回主循环。

省掉了什么

省掉的rayon 怎么做
睡前空转先 thread::yield_now 32 轮,第 32 轮宣布 sleepy,再找一轮才睡(状态机与计数器)
一把全池共享的锁每线程一个 CachePadded<WorkerSleepState>,唤醒方点名叫醒具体线程
每次 push 都 fence 并在有人睡时叫一个JEC 奇偶编码:没有人在变困就不碰锁;按「队列原来是否为空」和「醒着的空闲线程数」决定叫几个(唤醒策略与实测)
复查所有 deque只复查注入队列和本线程广播队列,内部任务允许漏看
广播队列、JobFifo、spawn每线程一个 FIFO 广播 deque、HeapJob + terminate_count(spawn 与 spawn_fifo)
跨池 installin_worker_cross:等的时候继续干本池的活;这里退化成「阻塞那个别的池的 worker」
panic 处理catch_unwind + panic_handler,没有 handler 才 abort;这里任何 panic 都 abort
Drop 不等线程rayon 的 ThreadPool::drop 只减 terminate_count、不 join;这里 drop 置 terminate、叫醒所有人并 join

小结

  • 三样东西拼出一个工作窃取池:每线程一个 LIFO Worker、所有 Stealer 的列表、一个 Injector;找活顺序本地、偷别人(Retry 整轮重来)、注入队列。
  • 睡眠四步:加锁、登记、SeqCst fence、复查,然后才 wait;唤醒方「push、fence、读计数、有人睡就拿锁 notify」。两道 fence 加同一把锁,排除丢唤醒。
  • 压力测试 10 次全过;去掉复查,10 次全部卡死,最早在第 136 次 install。
  • rayon 把同一套握手做得更省(每线程的锁、JEC、32 轮空转、只复查外部任务),代价是要论证更多的交错。

💬 关联讨论

发起讨论
加载中...