Rust in Everything
基于 rayon-core 1.13.0

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:三个字段

scope/mod.rs 第 21–54 行:

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::scopeinstall 进那个池再调 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 也是一个「任务」

scope/mod.rs 第 664–702 行:

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

scope/mod.rs 第 523–539 行:

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);
}

三步:

  1. 包成 HeapJob。闭包里拿的是 ScopePtr(self),一个裸指针包装(unsafe impl<T: Sync> Send for ScopePtr<T>,第 760–773 行),执行时再变回 &Scope;任务收到的 &Scope 参数就是这么来的,所以任务里还能接着 spawn。
  2. heap_job_ref:先 job_completed_latch.increment(),再 into_job_ref()(unsafe 的、允许非 'static 的那个版本)。先加计数后入队,任务就不可能在计数加上之前执行完、把计数减到 0。
  3. 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:只传播一个

scope/mod.rs 第 704–732 行:

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 赢)。

💬 关联讨论

发起讨论
加载中...