scope 家族
主线位置:一次 join 的推广。
join只能分出一个 B,scope能spawn任意多个任务,任务里还能再spawn;等待方式从「一个SpinLatch」换成「一个计数的CountLatch」,任务从栈上的StackJob换成堆上的HeapJob。
flowchart TB start["scope(op)"] --> iw["in_worker:进池,拿到 owner worker"] iw --> mk["ScopeBase::new:CountLatch 计数 = 1(代表 op)"] mk --> run["执行 op(接住 panic),期间每次 spawn:计数 +1,HeapJob 进 inject_or_push"] run --> dec["op 结束:计数 -1"] dec --> wait["job_completed_latch.wait(owner):边等边干活"] task["每个任务结束:接住 panic,计数 -1"] --> wait wait --> prop["maybe_propagate_panic:有就 resume_unwind"] prop --> ret["返回 op 的结果"]
scope/mod.rs 773 行,大半是文档。对外是四个函数(scope、scope_fifo、in_place_scope、in_place_scope_fifo)和两个类型(Scope、ScopeFifo),共用一个内部的 ScopeBase。
ScopeBase:三个字段
pub struct Scope<'scope> {
base: ScopeBase<'scope>,
}
pub struct ScopeFifo<'scope> {
base: ScopeBase<'scope>,
fifos: Vec<JobFifo>,
}
struct ScopeBase<'scope> {
/// thread registry where `scope()` was executed or where `in_place_scope()`
/// should spawn jobs.
registry: Arc<Registry>,
/// if some job panicked, the error is stored here; it will be
/// propagated to the one who created the scope
panic: AtomicPtr<Box<dyn Any + Send + 'static>>,
/// latch to track job counts
job_completed_latch: CountLatch,
/// You can think of a scope as containing a list of closures to execute,
/// all of which outlive `'scope`. They're not actually required to be
/// `Sync`, but it's still safe to let the `Scope` implement `Sync` because
/// the closures are only *moved* across threads to be executed.
#[allow(clippy::type_complexity)]
marker: PhantomData<Box<dyn FnOnce(&Scope<'scope>) + Send + Sync + 'scope>>,
}
registry:任务往哪个池里放。scope是调用线程所在的池(不在池里就是全局池),ThreadPool::in_place_scope是那个ThreadPool的池。panic:第一个 panic 的载荷,AtomicPtr指向一个Box<Box<dyn Any>>(外层Box把胖指针装成瘦指针,才能放进AtomicPtr)。job_completed_latch:Latch 家族里的CountLatch,初值 1。主人是 worker 时是Stealing变体(等的时候继续干活),不是 worker 时是Blocking(LockLatch)。marker:类型层面的注释。'scope出现在 trait object 的参数里,所以Scope<'scope>对'scope不变(invariant),编译器不能随意把它缩短或延长;Send + Sync让Scope自动得到Send/Sync。注释解释了为什么可以是Sync:任务闭包只会被移动到别的线程执行,不会被多个线程共享。
ScopeFifo 多一组 JobFifo,每个 worker 一个,JobRef 与几种 Job 讲过它怎么在 LIFO 的 deque 上做出 FIFO。
三种入口:op 在哪个线程跑
scope/mod.rs 第 277–286、398–426 行:
pub fn scope<'scope, OP, R>(op: OP) -> R
where
OP: FnOnce(&Scope<'scope>) -> R + Send,
R: Send,
{
in_worker(|owner_thread, _| {
let scope = Scope::<'scope>::new(Some(owner_thread), None);
scope.base.complete(Some(owner_thread), || op(&scope))
})
}
pub fn in_place_scope<'scope, OP, R>(op: OP) -> R
where
OP: FnOnce(&Scope<'scope>) -> R,
{
do_in_place_scope(None, op)
}
pub(crate) fn do_in_place_scope<'scope, OP, R>(registry: Option<&Arc<Registry>>, op: OP) -> R
where
OP: FnOnce(&Scope<'scope>) -> R,
{
let (thread, registry) = get_in_place_thread_registry(registry);
let scope = Scope::<'scope>::new(thread, registry);
scope.base.complete(thread, || op(&scope))
}
fn get_in_place_thread_registry(
registry: Option<&Arc<Registry>>,
) -> (Option<&WorkerThread>, Option<&Arc<Registry>>) {
let thread = unsafe { WorkerThread::current().as_ref() };
if thread.is_none() && registry.is_none() {
// A new global registry may use the current thread, especially on WebAssembly,
// so we have to re-check our current status after it's built.
let global = global_registry();
(global.current_thread(), Some(global))
} else {
(thread, registry)
}
}
op 在哪跑 | op 要 Send 吗 | 池外调用时等待方式 | |
|---|---|---|---|
scope | 先 in_worker 进池,在某个 worker 上跑 | 要 | 调用线程阻塞在 in_worker_cold 的 LockLatch 上;op 所在的 worker 用 Stealing 的 CountLatch 边等边干活 |
in_place_scope | 当前线程 | 不要 | 当前线程阻塞在 Blocking 的 CountLatch(LockLatch)上 |
ThreadPool::scope | install 进那个池再调 scope | 要 | 同 scope |
ThreadPool::in_place_scope | 当前线程,任务进那个池 | 不要 | 当前线程是别的池的 worker 时,边在自己的池里干活边等 |
in_place_scope 是 rayon 1.5.1 / rayon-core 1.9.1 加的(8815e26,合并于 #844):当 op 捕获了不是 Send 的东西(比如 Rc),或者调用方只想把一部分活交给池、自己留在原线程时用它。get_in_place_thread_registry 里那次「建完全局池再查一次当前线程」是 rayon-core 1.13.0 的修复(4208de2,合并于 #1259):在不支持线程的 WebAssembly 上,全局池就是当前线程本身,第一次调用前当前线程还不是 worker,建完才是。
最后一行的情况就是 CountLatch 注释里说的跨池场景:A 池的 worker 调 poolB.in_place_scope(..),ScopeBase.registry 是 B,CountLatch 的 Stealing 变体里存的是 A(主人所在的池),最后一个任务在 B 池线程上结束时,用存下来的 A 去叫醒主人。
complete:op 也是一个「任务」
fn complete<FUNC, R>(&self, owner: Option<&WorkerThread>, func: FUNC) -> R
where
FUNC: FnOnce() -> R,
{
let result = unsafe { Self::execute_job_closure(self, func) };
self.job_completed_latch.wait(owner);
self.maybe_propagate_panic();
result.unwrap() // only None if `op` panicked, and that would have been propagated
}
unsafe fn execute_job_closure<FUNC, R>(this: *const Self, func: FUNC) -> Option<R>
where
FUNC: FnOnce() -> R,
{
let result = match unwind::halt_unwinding(func) {
Ok(r) => Some(r),
Err(err) => {
(*this).job_panicked(err);
None
}
};
Latch::set(&(*this).job_completed_latch);
result
}
op 和每个 spawn 出去的任务走的是同一个 execute_job_closure:接住 panic 存起来,然后计数减一。计数初值的那个 1 就是给 op 的,所以 op 还在跑时计数不会归零。op 结束后 wait,等所有任务都 set 过;最后统一传播 panic。op panic 时 result 是 None,但这个 panic 一定已经存进 self.panic(或者另一个 panic 抢先存了),maybe_propagate_panic 不会返回,unwrap 不会执行到 None。
execute_job_closure 收 *const Self 而不是 &self,理由和 Latch::set 一样:set 之后 scope 的栈帧可能已经消失。
spawn:一律 inject_or_push
pub fn spawn<BODY>(&self, body: BODY)
where
BODY: FnOnce(&Scope<'scope>) + Send + 'scope,
{
let scope_ptr = ScopePtr(self);
let job = HeapJob::new(move || unsafe {
// SAFETY: this job will execute before the scope ends.
let scope = scope_ptr.as_ref();
ScopeBase::execute_job(&scope.base, move || body(scope))
});
let job_ref = self.base.heap_job_ref(job);
// Since `Scope` implements `Sync`, we can't be sure that we're still in a
// thread of this pool, so we can't just push to the local worker thread.
// Also, this might be an in-place scope.
self.base.registry.inject_or_push(job_ref);
}
三步:
- 包成
HeapJob。闭包里拿的是ScopePtr(self),一个裸指针包装(unsafe impl<T: Sync> Send for ScopePtr<T>,第 760–773 行),执行时再变回&Scope;任务收到的&Scope参数就是这么来的,所以任务里还能接着spawn。 heap_job_ref:先job_completed_latch.increment(),再into_job_ref()(unsafe的、允许非'static的那个版本)。先加计数后入队,任务就不可能在计数加上之前执行完、把计数减到 0。inject_or_push(registry.rs第 414–423 行):当前线程是这个池的 worker 就推本地 deque,否则注入全局队列。注释说明了为什么不能假定在池里:Scope是Sync,&Scope可以被传到任何线程上去调spawn;in_place_scope的op本来就可能在池外线程上。
spawn 没有返回值,任务只能通过共享状态(Mutex、channel、原子变量,或 scope 外声明的局部变量的 &mut)把结果交出来。执行顺序没有保证;文档说同一线程上 spawn 的任务大致按 LIFO 执行,被偷的按 FIFO。要本线程上 FIFO 就用 scope_fifo:它的 spawn_fifo(第 582–604 行)在 worker 上用 self.fifos[worker.index()] 推,不在池里就直接注入。
Scope::spawn_broadcast(第 545–558 行)给每个线程推一个 ArcJob 的 JobRef,每个也各加一次计数,见 broadcast。
'scope:借用为什么安全
spawn 的约束是 BODY: Send + 'scope:任务可以借用任何比 'scope 活得久的东西。scope(op) 在所有任务完成之前不返回,所以在 scope 调用之前就存在的变量,一定比所有任务活得久。反过来,在 op 里面声明的局部变量活不到 'scope 结束(op 可能早就返回了,任务还在跑),借用它会编译失败。compile_fail/scope_join_bad.rs 就是这个例子:
fn bad_scope<F>(f: F)
where F: FnOnce(&i32) + Send,
{
rayon_core::scope(|s| {
let x = 22;
s.spawn(|_| f(&x)); //~ ERROR `x` does not live long enough
});
}
fn good_scope<F>(f: F)
where F: FnOnce(&i32) + Send,
{
let x = 22;
rayon_core::scope(|s| {
s.spawn(|_| f(&x));
});
}
scope 的文档(第 100–210 行)用 ok / bad 两个变量详细讲了 move 闭包与借用的几种写法:要把 op 里的变量交给任务,就 move 进去;要借用外面的变量又要 move,就先在外面做一个 let ok = &ok; 的遮蔽。Scope 不变(marker 那一行)保证 'scope 不会被推断得比实际更短,这几种写法的对错由借用检查器判断。
ScopePtr 里那个裸指针的有效性,最终靠的是 complete 里的 wait:任务执行时 scope 还在 wait,Scope 所在的栈帧(scope 是在 in_worker 的闭包里建在栈上的)一定还在。
panic:只传播一个
fn job_panicked(&self, err: Box<dyn Any + Send + 'static>) {
// capture the first error we see, free the rest
if self.panic.load(Ordering::Relaxed).is_null() {
let nil = ptr::null_mut();
let mut err = ManuallyDrop::new(Box::new(err)); // box up the fat ptr
let err_ptr: *mut Box<dyn Any + Send + 'static> = &mut **err;
if self
.panic
.compare_exchange(nil, err_ptr, Ordering::Release, Ordering::Relaxed)
.is_ok()
{
// ownership now transferred into self.panic
} else {
// another panic raced in ahead of us, so drop ours
let _: Box<Box<_>> = ManuallyDrop::into_inner(err);
}
}
}
fn maybe_propagate_panic(&self) {
// propagate panic, if any occurred; at this point, all
// outstanding jobs have completed, so we can use a relaxed
// ordering:
let panic = self.panic.swap(ptr::null_mut(), Ordering::Relaxed);
if !panic.is_null() {
let value = unsafe { Box::from_raw(panic) };
unwind::resume_unwinding(*value);
}
}
先用一次 Relaxed 的 load 过滤掉「已经有 panic 了」的情况,没有才 CAS 抢位置;抢到的那个存下来,其余的直接 drop。ManuallyDrop 保证 CAS 成功时这个 Box 不会在函数结尾被释放(所有权已经交给 AtomicPtr)。CAS 用 Release,传播时用 Relaxed 的 swap,中间靠 CountLatch 的 SeqCst fetch_sub 与内部 latch 的 Acquire 建立先后(Latch 家族里 CountLatch 一节)。
文档说「多个 panic 时传播哪一个是不确定的」,并保证所有已 spawn 的任务都会执行,即使 spawn 它们的任务后来 panic 了。实验 E11(exp-rayon/panics/):
| 模式 | 每组 20 次 | 结果 |
|---|---|---|
4 个任务,job0、job2 panic | 四组 | job0 / job2 分别为 12/8、6/14、8/12、12/8;每组 80 个任务全部执行 |
op spawn 一个会 panic 的任务后自己也 panic | 四组 | op / job 分别为 14/6、18/2、18/2、20/0 |
谁先存进 AtomicPtr 谁赢,取决于哪个先执行完。第二种情况 op 多数时候赢:它在当前线程上立即 panic,任务要等别的线程来偷,或者等 op 结束后当前线程自己在 wait 里弹出来。
例子:tantivy 的多段搜索
tantivy 0.27.0 的 Executor::map 在多线程模式下这样用 scope:先把参数收集成 Vec,建一个 crossbeam 无界 channel,在 pool.scope 里给每个段 spawn 一个任务,任务通过借用的 &fruit_sender 把 (idx, 结果) 发回来;scope 返回后 sender 被 drop,接收端按 idx 放回一个 Vec<Option<R>> 的对应位置,还原参数顺序(第 82–99 行)。这里用到了 scope 的两点:任务借用了 scope 外的 f 和 sender(不需要 Arc),scope 返回时所有任务都已结束(接收端读完就是全部结果)。tantivy 卷的 Searcher 一章 讲了它在搜索路径上的位置。
小结
ScopeBase= 目标池 + 第一个 panic + 计数 latch(初值 1 代表op);op和每个任务都经execute_job_closure:接住 panic,计数减一。scope先进池再在 worker 上跑op,op要Send;in_place_scope在当前线程跑op,op可以不是Send,池外调用时当前线程用LockLatch阻塞。spawn先加计数、再把HeapJob交给inject_or_push:Scope是Sync,不能假定调用线程在池里。'scope由「scope等所有任务完成才返回」保证;op内部的局部变量不能被任务借用。- 多个 panic 只保留第一个存进
AtomicPtr的,哪个先完成不确定(E11:job0/job2各有胜负,op自己 panic 时多数是op赢)。