最小工作窃取线程池
主线位置:主线 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。
为什么这样就不丢唤醒,是两个配对:
- 登记和复查之间的 fence 对上唤醒方的 fence。 睡的一方「写
sleepers,fence,读队列」,唤醒方「写队列,fence,读sleepers」,是 store-buffering 形状,两边都有SeqCstfence,所以两边不可能都读到旧值:要么唤醒方看到sleepers > 0,要么睡的一方复查时看到新任务。crossbeam 的队列内部用的是 Release / Acquire,单靠它们挡不住这种形状,所以两道 fence 都要自己加。 - 唤醒方要先拿同一把锁再
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) |
跨池 install | in_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整轮重来)、注入队列。 - 睡眠四步:加锁、登记、
SeqCstfence、复查,然后才wait;唤醒方「push、fence、读计数、有人睡就拿锁 notify」。两道 fence 加同一把锁,排除丢唤醒。 - 压力测试 10 次全过;去掉复查,10 次全部卡死,最早在第 136 次
install。 - rayon 把同一套握手做得更省(每线程的锁、JEC、32 轮空转、只复查外部任务),代价是要论证更多的交错。