Rust in Everything
基于 crossbeam-utils 0.8.23

thread::scope 与 std 的 scope

主线位置:不在三条主线上。它是 crossbeam 最早的功能(仓库第一天的提交就叫「Basic scoped threads」),也是第一个被标准库正式取代的:Rust 1.63 起有了 std::thread::scope,crossbeam 的文档把自己的版本标成 soft-deprecated。

flowchart TB
  s["scope(f)"] --> cu["catch_unwind(f(&scope))"]
  cu --> wg["drop 自己那份 WaitGroup,wg.wait():等所有嵌套的 Scope 克隆被丢弃"]
  wg --> join["取出 handles 里还没被 join 的句柄,逐个 join,收集 Err"]
  join --> m{"f panic 了?"}
  m -->|是| ru["resume_unwind"]
  m -->|否| r{"有子线程 panic?"}
  r -->|否| ok["Ok(f 的返回值)"]
  r -->|是| err["Err(Box<Vec<Box<dyn Any + Send>>>)"]

普通的 thread::spawn 要求闭包是 'static,因为编译器不知道线程什么时候结束,借用的栈变量可能先被释放。作用域线程的承诺是:作用域结束之前,里面 spawn 的线程一定都 join 掉了,所以可以借用作用域外的变量。

crossbeam 的实现

thread.rs:153:

pub fn scope<'env, F, R>(f: F) -> thread::Result<R>
where
    F: FnOnce(&Scope<'env>) -> R,
{
    struct AbortOnPanic;
    impl Drop for AbortOnPanic {
        fn drop(&mut self) {
            if thread::panicking() {
                std::process::abort();
            }
        }
    }

    let wg = WaitGroup::new();
    let scope = Scope::<'env> {
        handles: SharedVec::default(),
        wait_group: wg.clone(),
        _marker: PhantomData,
    };

    // Execute the scoped function, but catch any panics.
    let result = panic::catch_unwind(panic::AssertUnwindSafe(|| f(&scope)));

    // If an unwinding panic occurs before all threads are joined
    // promote it to an aborting panic to prevent any threads from escaping the scope.
    let guard = AbortOnPanic;

    // Wait until all nested scopes are dropped.
    drop(scope.wait_group);
    wg.wait();

    // Join all remaining spawned threads.
    let panics: Vec<_> = scope
        .handles
        .lock()
        .unwrap()
        // Filter handles that haven't been joined, join them, and collect errors.
        .drain(..)
        .filter_map(|handle| handle.lock().unwrap().take())
        .filter_map(|handle| handle.join().err())
        .collect();

    mem::forget(guard);

    // If `f` has panicked, resume unwinding.
    // If any of the child threads have panicked, return the panic errors.
    // Otherwise, everything is OK and return the result of `f`.
    match result {
        Err(err) => panic::resume_unwind(err),
        Ok(res) => {
            if panics.is_empty() {
                Ok(res)
            } else {
                Err(Box::new(panics))
            }
        }
    }
}

几个部件:

  • handles: Arc<Mutex<Vec<Arc<Mutex<Option<JoinHandle<()>>>>>>>:每 spawn 一个线程往里推一个句柄。用户手动 join 时会 take() 掉,所以最后只 join 剩下的。
  • WaitGroup:每个子线程拿到一个新的 Scope 值(handles 是同一个 Arc,wait_group 是克隆,spawn 444–448 行),线程结束时这份克隆被 drop。wg.wait() 等的是「所有 Scope 克隆都没了」,也就是所有能再 spawn 的地方都结束了,之后 handles 不会再增长,才能放心地把它取空。这就是嵌套 spawn 的支持方式:WaitGroup 是它唯一的内部用户。
  • 生命周期擦除:子线程闭包是 'env 的,std::thread::Builder::spawn 要 'static,spawn 里 先 Box 成 dyn FnOnce() + Send + 'env,再 transmute 成 'static。安全性全靠「作用域结束前一定 join」。
  • AbortOnPanic:等待和 join 期间如果发生 unwind(例如某个 Mutex 被 poison,unwrap() panic),子线程可能还在用借来的栈变量,只能 abort。

子线程闭包的签名是 FnOnce(&Scope<'env>) -> T:多一个 &Scope 参数,用来在子线程里再 spawn(文档 81–112 行的例子解释了为什么不能直接借用外层的 s)。

门面 crate 把它重导出成 crossbeam::scope,只在 std feature 打开且不是 cfg(crossbeam_loom) 时有(src/lib.rs:82)。

std::thread::scope

std 在 1.63.0 稳定了自己的版本(#[stable(feature = "scoped_threads", since = "1.63.0")]),实现思路不同。library/std/src/thread/scoped.rs:141:

pub fn scope<'env, F, T>(f: F) -> T
where
    F: for<'scope> FnOnce(&'scope Scope<'scope, 'env>) -> T,
{
    // ...
    let result = catch_unwind(AssertUnwindSafe(|| f(&scope)));

    // Wait until all the threads are finished.
    while scope.data.num_running_threads.load(Ordering::Acquire) != 0 {
        // SAFETY: this is the main thread, the handle belongs to us.
        unsafe { scope.data.main_thread.park() };
    }

    // Throw any panic from `f`, or the return value of `f` if no thread panicked.
    match result {
        Err(e) => resume_unwind(e),
        Ok(_) if scope.data.a_thread_panicked.load(Ordering::Relaxed) => {
            panic!("a scoped thread panicked")
        }
        Ok(result) => result,
    }
}

不收集句柄,只数「还在跑的线程」,最后一个结束的线程 unpark 主线程。子线程 panic 时,只要它的结果没被 join 取走,线程结果(Packet)析构时就把 a_thread_panicked 置真(lifecycle.rs:171)。

crossbeam::scopestd::thread::scope
返回值thread::Result<R>,要 .unwrap()直接是 T
未 join 的子线程 panic返回 Err,内容是所有 panic 载荷的 Vecscope 自己 panic:「a scoped thread panicked」,原载荷丢失
子线程闭包FnOnce(&Scope<'env>) -> TFnOnce() -> T;嵌套 spawn 直接借用外层的 s:它是 &'scope Scope,活得和所有子线程一样久
等待方式WaitGroup + 逐个 join原子计数 + park / unpark
每次 spawn 的额外开销两个 Arc<Mutex<Option<…>>>(句柄、结果),推进共享 Vec 要拿锁无额外锁
f 自己 panicresume_unwindresume_unwind

文档里 soft-deprecated 的说明(thread.rs:138)是 2023-01 的 7df683c8 加的,理由写的是 std 版本「more efficient」。代码没有加 #[deprecated],编译不会警告。

实验 E12:子线程 panic 时两边怎么表现

断言:子线程 panic 且没被手动 join 时,crossbeam 返回 Err(Box<Vec<Box<dyn Any + Send>>>),std 直接 panic;没有 panic 时两边都正常返回;panic 的子线程被手动 join 过,两边在作用域层面都不再报告。

方法(exp-crossbeam/scope-panic):同一组情形分别用 crossbeam::scope 和 std::thread::scope 跑;std 那边包一层 catch_unwind 观察 panic 载荷;装一个空的 panic hook 让输出干净。

let res = crossbeam::scope(|s| {
  let h = s.spawn(move |_| {
    if child_panics { panic!("child boom"); }
    7
  });
  if join_explicitly {
    println!("explicit join -> is_err={}", h.join().is_err());
  }
  "scope body done"
});

结果(rustc 1.99.1,--release,跑 3 次输出相同):

crossbeam no-panic: Ok("scope body done")
std       no-panic: returned "scope body done"
crossbeam child-panics: Err, is Vec<Box<dyn Any + Send>>=true, payloads=["child boom"]
std       child-panics: panicked with "a scoped thread panicked"
  [child-panics-joined] explicit join -> is_err=true
crossbeam child-panics-joined: Ok("scope body done")
  [child-panics-joined] explicit join -> is_err=true
std       child-panics-joined: returned "scope body done"

断言成立。要注意 std 那一行:原来的 panic 载荷 "child boom" 不在 scope 的 panic 里,只能在默认 panic hook 打印的那行日志里看到;crossbeam 把载荷原样交还。从 crossbeam 迁移到 std 时,原来写成 .unwrap() 的地方行为基本不变(都 panic),写成 match / if let Err 处理子线程 panic 的地方就要改成对每个句柄 join()。这条会在 常见坑 里再出现。

小结

  • crossbeam 的 scope = catch_unwind(f) + WaitGroup 等所有嵌套 Scope 结束 + 逐个 join 剩下的句柄;子线程 panic 收集成 Err(Vec),f 自己 panic 则继续传播。
  • 子线程闭包多一个 &Scope 参数,用来嵌套 spawn;闭包生命周期靠 transmute 擦成 'static,等待期间再 panic 直接 abort。
  • std 1.63 的 std::thread::scope 只数运行中的线程,未 join 的子线程 panic 会让 scope 自己 panic,载荷丢失;返回值不包 Result。
  • crossbeam 文档自 2023-01 起把它标为 soft-deprecated,但没有 #[deprecated] 属性。

💬 关联讨论

发起讨论
加载中...