< 返回我的博客

爱国的张浩予 发表于 2024-12-12 07:51

Tags:auto-trait,send,sync,unpin,thread

自动特征auto trait的扩散规则

公式化地概括,auto trait = marker trait + derived trait。其中,等号右侧的markerderived是在Rustonomicon书中的引入的概念,鲜见于Rust References。所以,若略感生僻,不奇怪。

marker traitderived trait精准概括了auto trait功能的两面性

  1. 前者指明auto trait实现类具备了由rustc编译器和std标准库对其约定的“天赋异能 intrinsic properties”。
  2. 后者描述了这些“天赋异能”沿auto trait实现类数据结构【自内及外】的继承性与扩散性。

接下来逐一解释。

marker trait标识“天赋”特征是什么

既然是“天赋”,那么auto trait没有任何抽象成员方法待被“后天实现”或关联项待被“后天赋值” — 这也是marker trait别名的由来。 rustc甚至未配备专项检查器以静态分析与推断 @Rustacean 对auto trait的实现是否合理。类似于unsafe块,@Rustacean 需向rustc承诺:知道自已正在干什么,和提交可供其它程序模块信任的“天赋异能“代码实现。否则,运行时程序就会执行出未定义行为 U.B.。相比于传统的std程序接口和rustc内存安全承诺,这是一项“反向契约” — 即,由rustc充当“甲方”和规划功能要求,而由 @Rustacean 充当“乙方”完成功能代码和提供正确性保证。在硕大的Rust标准库中,这类“天赋异能“的”反向契约”并不多见,但包括

输入图片说明

derived trait明确“天赋”特征如何扩散

概括起来,auto trait的扩散规则就四个字“由内及外”。其遇到不同的场景,伴有不同解释的扩散链条

场景一:变量 ➜ 指针

以变量的数据类型为内,和以指向该变量值的指针/引用为外 — 变量值(数据类型T)实现的auto trait会自动扩散至它的各类指针与引用:

  • &T
  • &mut T
  • *const T
  • *mut T

所以,该扩散链条也被记作【类型 ➜ 指针】。

场景二:字段 ➜ 结构体

以字段的数据类型为内,和以父数据结构为外 — 所有字段(数据类型)都实现的auto trait会自动扩散至它们的紧上一层数据结构:

  • structs
  • enums
  • unions
  • tuples

所以,该扩散链条也被记作【字段 ➜ 结构】。

场景三:元素 ➜ 集合

以集合元素的数据类型为内,和以集合容器为外 — 由元素(数据类型T)实现的auto trait会自动扩散至该元素的紧上一层集合容器:

  • [T; n]
  • [T]
  • Vec<T>

场景四:捕获变量 ➜ 闭包

以捕获变量的数据类型为内,和以闭包为外 — 所有捕获变量(数据类型)都实现的auto trait会自动扩散至引用(或所有权占用)这些捕获变量的闭包。

场景五:函数 ➜ 函数指针

函数项fn与函数指针fn ptr总是会被rustc编译时自动实现全部auto trait

扩散链条的“串连”

前四个场景的扩散链是可以多重嵌套衔接的。举个例子,Vec<Wrapping<u8>>一定满足trait Send限定条件,因为这条扩散链条的存在:

输入图片说明

再举个更复杂的例子,假设有如下枚举类

enum Test<'a> {
    Str(&'a String),
    Num(u8)
}

那么Vec<Test>也一定满足trait Send限定条件,因为此扩散链条的存在:

输入图片说明

auto trait扩散链条的“阻断”

  1. 安装nightlyrustc编译器。然后,在代码中,
  2. 开启#![feature(negative_impls)]feature-gate编译开关
  3. 否定实现auto trait。比如,impl !Unpin for Test {}

于是,rustc就不会再对遇到的【类型定义】自动添加曾被否定实现过的auto trait了。在众多auto trait中,仅trait Unpin绕过nightly编译工具链的依赖和仅凭stable标准库内符号类型std::marker::PhantomPinned定义的幻影字段阻止rustc悄悄地实现自动特征。

【幻影字段】是仅作用于编译时的零成本抽象项。它被用来帮助编译器理解 @Rustacean 提交的代码和推断 @Rustacean 的程序设计意图。其语义功能很像typescript中的【@ 装饰器】。即,

  1. 辅助代码静态分析
  2. 辅助编译器生成垫片程序
  3. 编译后立即抹除
  4. 对【运行时】不可见 — 这也是【零成本】的由来。但,世间任何事物都有两面性和是双刃剑。“零成本”是省CPU,但更费脑细胞呀!Rust编程的心智成本高已是行业共识了。

另值一提的是,【Rust幻影字段】与【typescript装饰器】皆都不同于【Java的 @ 注释】,因为

  • 前者是给编译器看和解读的 — 充其量是代码正文的旁白注脚。
  • 后者是给运行时VM用和执行的 — 这已算是正文指令的一部分了。

它们就是两个不同“位面”的东西。

我日常仅用过std::marker::PhantomPinnedstd::marker::PhantomData两类幻影字段

  • 前者解决stable编译工具链对trait Unpin的否定实现 — 就是这里正在讲的事
  • 后者被用于“类型状态Type State Pattern”设计模式中,将【运行时】对象状态的(动态)信息编码入【编译时】对象类型的(静态)定义里,以扩展Rust类型系统的应用场景至对状态集的“状态管理”。若您对“类型状态”设计模式有兴趣,推荐移步至我的另一篇主题文章对照 OOP 浅谈【类型状态】设计模式保证有收获。

这闲篇扯远了,让咱们重新回到文章的主题上来。

请细读下面自引用结构体的类型定义(特别含注释内容)和体会std::marker::PhantomPinned如何被用来声明结构体内的幻影字段:

use ::std::marker::PhantomPinned;
struct SelfReferential {
    // 整个结构体内唯一包含了有效信息的字段。
    a: String,
    // 自引用前一个字段`a`的值。
    ref_a: *const String,
    // 1. 这是对【幻影字段】的定义
    // 2. 因为该字段并不会真的被后续功能代码用到,
    //    所以为了压制来自编译器的`useless field`警告,
    //    字段名以`_`为前缀
    _marker: PhantomPinned 
} // 于是,自引用结构体`Test`就是 !Unpin 的了

即便不太理解,也请不要质疑【自引用结构体】的存在必要性。至少在异步程序块中,跨.await轮询点的变量引用都依赖这套机制。仅因为async {}语法糖把每次构造trait Future实现类的细节都隐藏了起来,所以 @Rustacean 对自引用结构体的直观感受会比较弱。

Send / Sync trait扩散规则的例外

  1. 原始指针*mut T*const T
  2. 内部可修改容器(智能指针)std::cell::UnsafeCellstd::cell::Cellstd::cell::RefCell
  3. 引用计数智能指针std::rc::Rc

沿着扩散链条,一旦遇到上述三者之一,Send / Sync trait的扩散势头就会戛然而止。更外层的数据结构就不得不考虑自己手工实现Send / Sync trait了。

题外话,虽然【原始指针】自身是提不上线程安不安全的,但使用【原始指针】实现任何有意义的处理逻辑需要对其进行解引用。而“原始指针的解引用”却是概念明确的“非线程安全”。所以,为了减少技术细节对 @Rustacean 的羁绊,我们就一概而论地讲:“【原始指针】都不是线程安全的”。

auto trait扩散不至的自定义数据结构

若数据结构定义内含有实现auto trait的字段(比如,

use ::std::env::Vars;
struct Dumb(Vars);

其中Dumb.0字段Vars明确是!Send的),那么 @Rustacean 就有必要考虑

  • 要么,重新规划程序设计,以规避要求struct Dumb满足trait Send限定条件

  • 要么,给struct Dumb添加unsafeauto trait实现块。

    unsafe impl Send for Dumb {};
    

后者的unsafe impl语法前缀就是rustc对程序作者最后的警告:“你真的明白,你正在做什么事吗?”。

【快排序】 综合例程

先贴源码,再做详解。敲黑板强调:代码内的注释同样重要呀!推荐同正文一样重视和仔细阅读。

fn quick_sort<T: Ord + Send>(v: &mut [T]) {
    if v.len() <= 1 {
        return;
    }
    let mid = {
        let pivot = v.len() - 1;
        let mut i = 0;
        for j in 0..pivot {
            if v[j] <= v[pivot] {
                v.swap(i, j);
                i += 1;
            }
        }
        v.swap(i, pivot);
        i
    };
    // 1. 此处,虽然 lo 与 hi 是两个崭新的【切片】胖指针实例,
    //    但由【切片】胖指针引用的底层 Vec<i32> 数据值却只有
    //    一份呀!
    let (lo, hi) = v.split_at_mut(mid);
    // 2. 所以,后续的【多线程+递归】是修改的同一个 Vec<i32> 
    //    实例。
    rayon::join(|| quick_sort(lo),
                || quick_sort(hi));
    // 3. 至此,虽然此函数没有返回值,但仍可沿函数的【输入输出】
    //    实参 v: &mut [T] 向调用端传递排序结果。
}
fn main() {
    use ::rand::prelude::*;
    // 1. 生成一个大数字集合
    let mut numbers: Vec<i32> = (1000..10000).collect();
    // 2. 乱序数组内容
    numbers.shuffle(&mut rand::thread_rng()); 
    // 3. 多线程快排序
    quick_sort(&mut numbers); 
    // 4. 打印排序结果
    println!("Sorted: {:?}", &numbers[..10]);
    // 5. 一个单例 Vec<i32> 对象贯穿整个多线程快排序 demo 始终。
}

上述例程的难点并不是rayon::join()如何在后台悄悄唤起多个线程加速大数据集【快排序】 — 这不需要 @Rustacean 操心,咱们只要多读读 API 文档和了解Work Stealing工作原理就足够了。相反,曾经困惑过我一段时间的痛点是:

为什么虽然泛型类型参数<T: Ord + Send>的书面语义是“跨线程·数据复制”但程序的实际执行结果却是对Vec<i32>单例的“跨线程·内存共享”?即,多个线程透过【切片】胖指针,修改同一个Vec<T>变长数组实例。

推导明白这条逻辑链(元素Send ➜ 集合Sync)先后耗费了我不少心神。但总结起来也无非如下几步:

  1. 依据前文介绍的auto trait扩散规则,对特征trait Send和泛型类型参数T,构造初始扩散链条:

    输入图片说明

  2. 依据trait Sync的精确定义(如下),有<&S: Send> → <S: Sync>

    输入图片说明

  3. 将 #2 代入 #1,进一步完善trait Send扩散链条,有

    输入图片说明

  4. 依据rustc赋予trait Sync的语义,集合[T]被允许跨线程引用与多线程共享

  5. 又因为fn quick_sort()的形参是对Vec<i32>实例的可修改引用&mut,所以多个线程被允许并行修改同一个变长数组实例。

你不会以为故事就此结束了吧?难道你没有发觉例程中多线程代码有缺了点儿什么的异样吗?没错!细心的读者可能早就想问:

对单实例变长数组的并行修改,为什么未采用【读写锁RwLock】或【互斥锁Mutex】加以同步保护呢?甚至rustc在编译时连警告提示都没有输出?What's wrong?

好问题!您有心了。快速回答是:虽然Vec<i32>实例同时被多个线程并行修改不假,但每个线程并行修改的切片却只是同一变长数组内彼此衔接却并不相交的“子段”。所以,在快排序过程中,事实上没有任何数据竞争发生 — 这是彻头彻尾的算法胜利。果真,编程的尽头是数学与算法啊!此外,rustc能顺利地接受与成功地编译这样的代码也足已破除人们以往对它保守且不变通的刻板印象。

结束语

这次就先分享这一个小知识点。文章里埋的有关Unpin的坑以后再填。2024搞了一年的“鸿蒙Next ArkTs”真不容易。哎!天大地大,饭辙最大。我是一颗螺丝钉,甲方爸爸需要什么,我就研究什么。但,年底写篇Rust知识分享文章压压惊。

微信转载文章链接

评论区

写评论
Kaurus 2025-08-16 02:09

不好意思,可能是“不重叠”可能混淆了你的视线,这里的关键点是&mut[T],lo, hi 它们是独占的,而切分前的 v 已经不可以用了。

&mut[T] 只能被一个线程可变借用,不然就违反 ownership 规则了,所以根本谈不了在多个线程间共享。

而你手工切片,强行让 lo, hi 重叠,已经破坏了 &mut[T] 独占的语义,这说明不了什么。

这里只需要理解 ownership 就能很好地解释这个程序的逻辑。

依据rustc赋予trait Sync的语义,集合[T]被允许跨线程引用与多线程共享

又因为fn quick_sort()的形参是对Vec实例的可修改引用&mut,所以多个线程被允许并行修改同一个变长数组实例。

fn test_sort(v: &mut [i32]) {
    std::thread::scope(|s| {
        s.spawn(|| v.sort());
        s.spawn(|| v.sort());
    });
}

根据你的推论,[i32] 是 Sync 的,允许跨线程引用与多线程共享,而形参是 &mut,所以允许多个线程同时修改。但实际上 rustc 不允许这段代码编译,&mut 独占,不可能被两个线程同时可变借用然后并发修改。

如果你的推导和事实标准 rustc 不符合,那有没可能是推导错了?

很不幸,之前我的理解也错了。

因为我理解错了 Sync 的含义,一个类型是Sync 的并不代表它能直接被多个线程并发修改,它只代表能多线程共享&T。

Rustonomicon 的 Carton 例子就很好:

Since you need an &mut Carton to write to the pointer, and the borrow checker enforces that mutable references must be exclusive, there are no soundness issues making Carton sync either.

Carton 被标记成Sync了,但是你实际没办法并发修改,因为被 borrow checker 限制了不能同时有多个&mut。

类似的,根据 auto trait 规则,&[i32] 和 &mut [i32]是 Sync 的,但也不能并发修改。&[i32]没有interior mutability 所以不能通过 shared reference 修改,而 &mut 独占又不能被多个线程同时借用。

更简单的例子,基本类型如 i32 是 Sync的,&i32 能多线程并发读,但是不能改。而能修改的 &mut i32 又不能多线程同时借用。

auto trait 的规则没什么问题,从 T: Send 推导 T: Sync 也没问题(前提当然是没有!Sync,auto trait 规则能生效)。 但是解释不了最后 quick sort 的例子,因为 Sync 只表示可以多线程共享,不表示能并发读写。而&mut 被borrow checker 限制了不能同时多个借用,也不存在并发读写的可能。然后它们两个结合起来,就可以并发读写,我是完全不能理解的。而你手工切分然后崩溃的例子也间接说明了,它们结合在一起并不能保证正确。

作者 爱国的张浩予 2025-01-01 19:02

第一,文章中写<&S: Send> → <S: Sync>的依据是特征std::marker::Sync的精确定义。请见下图,

输入图片说明

第二,在Rustonomicon书中,CellRefCell都被归入例外处理范围了。所以,你提及的明显错误情况不会出现。请见下图,

输入图片说明

第三,函数fn quick_sort()被允许跨线程接收胖指针&mut [T]应该和“std::slice::split_at_mut()是否分隔出不重叠的子切片”无关,因为即便咱们将【快排序】例程中的v.split_at_mut(mid)调用语句替换为如下代码:

// 
// 纯手工分隔出相互重叠的切片。函数`split_at_mut`也按这个套路实现的。
// 
let ptr = v.as_mut_ptr();
let (lo, hi) = unsafe { (
    std::slice::from_raw_parts_mut(ptr, mid + 1),                        // 前一段向后多一个元素
    std::slice::from_raw_parts_mut(ptr.add(mid - 1), v.len() - mid + 1), // 后一段向前多一个元素
) };

编译一样能够通过。只是程序会运行时崩溃。 在编译过程中,rustc对 @Rustacean 提交代码的业务逻辑没有做那么多假设。

第四,关于你提供例程中的编译失败“^ T cannot be shared between threads safely”,我目前还给不出解决办法,因为&Send → Sync确实走不通linter的泛型推导。但从教条语义上,因为&[T]Send的,所以该胖指针背后的[T]就被允许跨线程共享与修改 — 程序执行效果与trait Sync精确定义完美契合。

--
👇
Kaurus: 从 T: Send 推到 [T]: Sync 有点怪。

fn is_sync<T: Sync>(_: T) {}

fn is_slice_sync<T: Send>(t: &[T]) {
    is_sync(t)
    // -- ^ `T` cannot be shared between threads safely
}

这样可以试出 T: Send 不能推到 [T]: Sync。 举个例子,Cell,如果能推出 Sync 就可以被多个线程同时修改,明显不对。

从 T: Send 推到 &mut T:Send 和 &mut [T]: Send 就没问题:

fn is_send<T: Send>(_: T) {}

fn is_exclusive_ref_send<T: Send>(t: &mut T) {
    is_send(t)
}

fn is_exclusive_slice_send<T: Send>(t: &mut [T]) {
    is_send(t)
}

fn is_exclusive_slice_sync<T: Send>(t: &mut [T]) {
    // is_sync(t) // of course not
}

因为 &mut 是独占的,转发给另一个线程它依然只有一个线程读写,所以不需要 Sync。

所以 quick_sort 能行的原因是 split_at_mut 返回两个不重叠的 &mut[T],非别发给不同一个线程,不需要同步。

Kaurus 2024-12-27 23:58

从 T: Send 推到 [T]: Sync 有点怪。

fn is_sync<T: Sync>(_: T) {}

fn is_slice_sync<T: Send>(t: &[T]) {
    is_sync(t)
    // -- ^ `T` cannot be shared between threads safely
}

这样可以试出 T: Send 不能推到 [T]: Sync。 举个例子,Cell,如果能推出 Sync 就可以被多个线程同时修改,明显不对。

从 T: Send 推到 &mut T:Send 和 &mut [T]: Send 就没问题:

fn is_send<T: Send>(_: T) {}

fn is_exclusive_ref_send<T: Send>(t: &mut T) {
    is_send(t)
}

fn is_exclusive_slice_send<T: Send>(t: &mut [T]) {
    is_send(t)
}

fn is_exclusive_slice_sync<T: Send>(t: &mut [T]) {
    // is_sync(t) // of course not
}

因为 &mut 是独占的,转发给另一个线程它依然只有一个线程读写,所以不需要 Sync。

所以 quick_sort 能行的原因是 split_at_mut 返回两个不重叠的 &mut[T],非别发给不同一个线程,不需要同步。

jiemo2187 2024-12-26 20:13

好优秀

SunBobJingtao 2024-12-26 09:19

写的真不错

WP25 2024-12-16 11:10

庖丁解牛,鞭辟入里,膜拜大佬~~~

ajiao401 2024-12-13 10:08

学到了,为作者点赞

codeyw231 2024-12-12 17:29

写的非常详细,膜拜大神,感谢分享!

SunBobJingtao 2024-12-12 09:29

哇,论述好精妙!

1 共 9 条评论, 1 页