
文章目录
- 1. 故事始于“数据竞争”
- 2. Sync 堵上了不可变引用的漏洞
- 3. Send 堵上了跨线程 move 的漏洞
- 4. Send / Sync 更深层成因的洞察
- 5. 在打标记这件事上编译器做了什么?
- 6. 为什么其他语言里没有 Send / Sync
坦率地说,目前大多数介绍 Send / Sync 的文章都有些“隔靴搔痒”的感觉,它们确实介绍了这两个 Trait 但读完之后又感觉好像什么也没说。Send / Sync 只是两个标记特质(Marker Trait),没有任何关联方法,它们唯一的用途是出现在约束里,让编译器做类型检查。只需寥寥数语,Send / Sync 就解释完了,但这也正是它们最难理解的地方,很多人都对这两个 Trait 的由来和作用感到困惑,要完全理解它们既需要对 Rust 的所有权和借用检查有深刻的认识,又需要一个与线程安全相关的“上下文”。本文,我们会带领大家把 Send / Sync 这两个 Trait 彻底搞明白。
1. 故事始于“数据竞争”
数据竞争不是线程安全中唯一的问题,但一定是发生频率和讨论度最高的。在多线程环境下,如果有两个以上的指针或引用指向同一个数据,且至少有一个指针或引用会写数据,在没有同步保护(例如加锁)的情况下就会出现“数据竞争”问题。多线程环境中“转账问题”就是一个非常典型的案例:正常情况下,多个账户之间相互转账,无论经历多少轮操作,所有账户的金额总和是不会变的;但一旦变成多线程并发操作,如果不对“账户余额”施加同步保护,则多轮转账过后,所有账户的金额总和就可能会发生改变,这是很严重的 bug,但只会在多线程环境下才会发生,并且很难追踪,因为转账操作本身并没有逻辑错误。在这个案例中,账户总金额被改变的这种现象叫“数据腐蚀”,账户余额就是被“竞争”的数据,数据腐蚀是数据竞争导致的一种典型错误。
在一般的编程语言中是如何避免数据竞争的呢?答案是:不管!这是程序员的事!并非是这些语言“傲娇”,让编译器去分析程序的动态执行过程并找出潜在的并发问题是一种很“天真”的想法,这超出了编译器的能力范围。那 Rust 呢?在这个问题上,Rust 不是一门“一般”的语言,针对这个其他语言都不管的问题,Rust 有自己的应对之道,但它的“管”法并不是落脚到线程机制上,而是靠它的所有权和借用规则“硬控”了数据竞争,很奇怪对吗?这是怎么发生的呢?
最初,为了应对内存安全问题,Rust 规定:“对于一个值而言,在任意时刻,要么有任意多个不可变引用 &T(共享访问,只读),要么有且只有一个可变引用 &mut T(独占使用,可读可写)”。后来,人们发现借用规则同样适用于线程安全,它明确阻止了产生数据竞争的两个必要条件:Aliasing(别名,就是“共享读取”)和 Mutation(可变,就是“修改”)被同时满足。尽管这已经解释得很直白了,但为了防止部分基础薄弱的读者理解不了,我再解释一下,这也是我们本节产出的最关键结论:
由于借用规则规定:对于一个值而言,在任意时刻,要么有任意多个不可变引用 &T(共享访问,只读),要么有且只有一个可变引用 &mut T(独占使用,可读可写),则在多线程环境下,永远不会出现:一个线程正在通过 &T 读取值的时候,另一个线程正在通过 &mut T 改写值的情形,这是借用规则可以从根本上消除数据竞争的核心原因。
关于为什么借用规则能“跨界”作用到线程安全上,这是一个很“迷”的话题,可以确定的是:在设计借用规则之初,人们确实不知道它也能用于消除数据竞争,这是后来才发现的“红利”。Rust 官方博客曾于 2015 年发表过一篇文章《Aaron Turon, Fearless Concurrency with Rust》,介绍了 Rust 的所有权和借用系统最初就是为内存安全设计的,只是这套规则恰好也能消除数据竞争,因此并发安全在很大程度上算是 Rust“白捡”的。
2. Sync 堵上了不可变引用的漏洞
倘若 &T 一直是“无懈可击”的,那么我们关于“数据竞争”的讨论在上一节就“完美收官”了,然而,不出意外的话,一定会有意外!在 Rust 中,有个 UnsafeCell 类型,如果你不熟悉它,那至少应该知道 Cell 和 RefCell,它们都是用来提供“内部可变性”的特殊类型,UnsafeCell 是 Rust 所有“内部可变性”底层能力的提供者,Cell 和 RefCell 是建立在 UnsafeCell 之上的高级封装。
我们都知道 Cell 和 RefCell 的能力,它们可以让一个原本不可变的引用 &T 绕过借用检查获得“局部可变性”,这样,&T 就不再是绝对意义上的“只读”了,这个“后门”一旦打开,原本设定的“共享读取”就变成“共享修改”了,数据竞争将不可避免。
很头疼的一个问题,局部可变性是我们所需要的一种机制,Cell 和 RefCell 本身没错,现在我们得想办法把这个“漏洞”堵上。既然一个类型 T 的不可变引用 &T 不再是 100% 线程安全的(这里是我们本文第一次使用:“一个类型是线程安全”的这种表述,这种叫法不会出现在其他语言里,如前文解释的那样,在其他语言里,线程安全跟具体类型无关,只取决于线程对数据有没有并发读写,但是,在 Rust 里这种表述就变成有意义的了,因为受到所有权和借用规则的约束,一个类型的值或引用的数量和出现的位置都是严格受控的,由于这些规则的限制,绝大多数类型不可能在多线程环境中出现既读又写的情形1,所以说它们是“线程安全”的,而另外一些极少数的类型则通过各种“后门”绕过这些限制,那它们就是“线程不安全”的,在后面我们都会详细解释,总之,在本文,我们说“一个类型是线程安全”的意思就是说在多线程环境中不会出现一个线程在读它,另一个线程在写它的情形),那么,我们就单独引入一个标签来标记这个类型的不可变引用是不是绝对的线程安全(确保只读,无修改部分数据的能力),这就是 Sync:
Sync:标记这个类型的值可以安全地被多个线程并发访问,也就是在多个线程中使用它的不可变引用 &T是安全的
同样的,如果一个类型在多线程环境中使用它的不可变引用 &T 是不安全的,就要使用 !Sync 标记。
3. Send 堵上了跨线程 move 的漏洞
除了不可变引用的漏洞外,还有一个更隐秘的漏洞,那就是:类型的值跨线程 move 后也可能会出现线程安全问题,这也是借用规则管理不到的“死角”,需要人工进行标记。这个问题理解起来比 Sync 困难许多,需要大家对所有权有一个清晰透彻的理解。
首先,我们要弄明白:在线程间 move 一个值为什么会有潜在的线程安全问题?看问题要到根上去找原因,我们说一个值跨线程 move 之所以会有线程安全问题(本文我们特指数据竞争问题)一定是有什么数据在 move 后会出现了既被读又被写的情况,或者说本来这种数据就是既被读又被写的,只是在单线程环境下被人们忽略了,那会是什么数据呢?我们首先怀疑的对象就是值本身,但是,这个怀疑其实很容易排除,问题描述的是值的 move,move 只会改变所有权(由编译器记录下谁(哪个变量(绑定))将负责最后销毁值),不会修改任何数据。这里可能有人会担心:也许是 move 后,在新线程中又创建了不可变或可变引用呢,然后和再原线程中的引用一起来竞争值呢?这可能是很多人担心过,但又未必能清晰描述出来的场景,这里其实有好多臆想出来的假设,但实际上它们根本没法编译通过。针对这一种怀疑,我们在另一篇文章《一个 Rust 多线程的笼统问题:一个值和它的引用交叉分布于不同线程中可以有多少种情况?哪些是合法的?》进行了详细的解释,简单概括一下结论就是:我们预想的几乎所有非法的值与引用交叉分布于不同线程中的情形,都在编译期被所有权和借用检查 + spawn 强制使用 move 闭包( 'static 约束)“毙”掉了。总之,可以确定的是:一个值跨线程 move 如果有线程安全问题,不会是值本身有既读又写的情况。
如是不是值,那还能是什么呢?接下来,我们就不能再进行无实物推论了,得结合实际类型 Rc 来分析了,因为 Rc 是一个大家熟知的 !Send 类型,也就是跨线程 move 有安全问题的类型。在开始分析前,先说明一下,实际上,很多人对“Rc 类型跨线程 move 不安全”这个问题的困惑并来自于多线程,而是对 Rc 的内部实现和所有权的理解上有欠缺,如果想要透彻理解这个问题,强烈建议阅读一下《Rust 所有权进阶必读:为什么 Rc 共享所有权是个伪概念?》,实际上,这篇文章正是在研究这个问题的过程中衍生出来的。
我们知道:Rc 对包裹的“数据”赋予了“共享所有权”的能力,但正如我们在《Rust 所有权进阶必读:为什么 Rc 共享所有权是个伪概念?》中解释的那样,这是一个伪概念,是多重概念错位叠加出来的一个糟糕术语,实际的情形是:Rc 通过 clone 创建出的多个值(实例),每个值都有自己唯一的所有者,不存在“共享所有权”这种说法,而多个值共同管理堆上的“资源”,也就是 Rc 包裹的数据。下图为 Rc 的语义模型(引用自《Rust 所有权进阶必读:为什么 Rc 共享所有权是个伪概念?》)

我们前面分析过:一个值跨线程 move 如果有线程安全问题,不会是值本身有既读又写的情况,那还有什么其他数据呢?Rc 给我们提供了更宽泛的视野,如果不是值,那就只能是资源了!当一种类型它能构造出多个相同的值指向同一份资源的局面时,若值对资源既可读又可写,那当这些值分散于不同的线程中时,就必然会出现数据竞争了!它们竞争的是资源中的数据!这是 Rc 跨线程 move 不安全的根本原因!
但是,以上只是第一层解读,我们要确认是资源里的什么数据会被值改写,包裹在 Rc 里的用户数据吗?不是。因为:我们只能通过 Rc<T> 拿到 &T,拿不到 &mut T2,因为 Rc 没有实现 DerefMut,显然,这是刻意为之的,如果允许通过 Rc 实例获得可变引用,那多个 Rc 值就意味着多个可变引用,这是绝对不允许的,Rc 设计者就是在“防着这一手”。
如果不是 Rc 包裹的用户数据,Rc 值管理的“资源”里还有什么数据呢?那就只剩下“引用计数”了,虽然它不由用户直接读写,相当于 Rc 的元数据,但它确实是和用户数据一起存放在堆上的 RcBox 里,是名副其实的“资源”,是的,这个引用计数就是最后的“漏洞”,是多个 Rc 值可能会跨线程并发读写的数据!它没有被施加同步保护,所以成了“Rc 跨线程 move 会不安全”的根本原因!
4. Send / Sync 更深层成因的洞察
相信从前面两节的分析中你也应该能感受到:尽管 Sync 和 Send 针对的是两种不同的场景,但其实它们标记的问题性质是一样的:它们就是标记一种类型在多线程环境中会不会出现对内部数据既读又写的情况,它们的区别只是既读又写的数据不同,前者是值本身,后者是多个相同值共享的资源。
作为最终的解惑,我们想知道:既然它们的区别是既读又写的数据不同,那它们获得对数据“既读又写”能力的途径是什么?是同一种机制还是不同的机制?关于这个问题,很多人可能会笼统的认为是一个类型使用 unsafe 编写了一些不安全的代码导致的,所以会将 !Sync / !Send 和 unsafe 联系在一起,这是一个很大的误解,unsafe 代码不是导致 !Sync 和 !Send 的原因,反而是为了修复它们才会出现的。
真实情况是:以 Cell/RefCell 为代表的局部可变性和以 Rc 为代表的构造多个相同值对共享资源(引用计数)进行读写这两种情形,实现它们用到了两种代表性的特殊语言机制,这是导致它们分别成为 !Sync 和 !Send 的根本原因。
首先,局部可变性能力完全是通过 UnsafeCell 获得的,而这个 UnsafeCell 绝不是一个简单的类型,它不是那种靠写代码就能实现出来的类型,而由编译器提供了一些特殊支持(就像编译器对 Fn trait 提供了特殊支持那样,它们都用 #[lang = “…”] 进行了标记)。也就是说:用户根本造不出第二个 UnsafeCell,整个 Rust 语言里,能破坏 Sync 的只有 UnsafeCell 这一处“后门”,正因为如此,编译器才能进行 auto trait,也就是通过递归检查类型的字段中是否含有 UnsafeCell 类型来判断类型是否是 Sync 的!
然后,我们再看一下以 Rc 为代表的构造多个相同值对共享资源进行读写,这个问题,我们得分成两部分来看,Rc 的线程安全问题首先是因为它能创造出多个相同的值,没有这个能力,就不会 形成“多个值”跨线程读写资源的局面,然后才是对引用数据没有进行同步保护。所以 Arc 的补救措施就是给引用计数添加上同步保护(不是用的锁机制实现的,而是通过原子类型 AtomicUsize 实现的)。
针对第一个成因,它之所以能够做到让多个值指向同一个资源,主要是靠裸指针,裸指针既无值的所有权概念也不受制于借用检查(借用检查只针对引用类型),这使得它非常“自由”,没有拘束(当然,一旦用了它就得靠程序员自己来把控内存安全了),一方面,它近似于 Copy 类型,可以随意按位复制,所以它可以创建出多个指向同一位置的值,Rc 值(一个指针)就是靠裸指针的这个特点实现的 clone;另一方面,因为裸指针不受借用检查的约束,所以裸指针也没有生命周期的概念,不存在引用才会有的那些不能活得比它的值还要久之类的约束。最后,我们再集中梳理一下能让一个类型被 auto trait 自动判定为 !Send 和 !Sync 的语言机制都有哪些:
| *const T / *mut T | Send + Sync | 语言内建的负实现 |
| UnsafeCell | Sync | lang item |
| std 里几个显式负实现(Rc、MutexGuard 等) | 各自 | 标准库作者手动打上的标签 |
注意,以上设置的前提是 auto trait,也就是你的自定义类型含有了以上类型的字段,而你没有通过编写“补救”代码挽回它们的“线程安全性”的前提下,自动判定的结果。
5. 在打标记这件事上编译器做了什么?
如果没有以上介绍的这些特殊机制打开的“后门”,凭借所有权和借用检查,我们可以说在 Rust 中所有的类型都是线程安全的(仅针对数据竞争而言),但由于这些特殊机制的存在,Rust 就不能再默认所有的类型都是线程安全的,所以才引入了 Send 和 Sync 单独进行标记,但是,如果每一种类型都要手动标记的话又会很繁琐,于是,Rust 提供了 auto trait 机制,也就是由编译器自动为类型实现 Send 或 Sync。
不过,编译器的 auto trait 只负责判定一个类型是否应该标记为 Send / Sync,不负责判定 !Send / !Sync。摊开看的话,一个类型其实有三种状态:正标记(Send / Sync)、无标记、负标记(!Send / !Sync),无标记和负标记还是有区别的,负标记必须是手动且主动添加上的,用于明确标记:这个类型是线程不安全的,而无标记表示:这个类型的线程安全性不明或者说没有考虑过它在多线程中的安全性。
auto trait 在判定一个类型是否是 Send / Sync 时,会自动递归检查类型的所有字段,如果字段(包括嵌套字段)不含有任何的 !Send / !Sync 类型,则整个类型就被自动推断为 Send / Sync,只出现过任何一个 !Send / !Sync 字段,auto trait 就不会自动标记了。以下列举了我们日常编程中会遇到的一些典型类型的 Send / Sync 标记情况,你的定义类型的 Send / Sync 标记基本上就取决于你的类型中有没有用到这些类型的字段了:
| i32, String, Vec(T 满足) | 是 | 是 | 纯数据,无内部可变性/共享 |
| *const T / *mut T | 否 | 否 | 裸指针无任何保证,保守拒绝 |
| UnsafeCell | 是 | 否 | 内部可变性的唯一来源:共享引用也能改数据 |
| Cell / RefCell | 是 | 否 | 内部含 UnsafeCell |
| Rc | 否 | 否 | 引用计数非原子,跨线程会计数错乱 |
| Arc | T: Send+Sync | 同左 | 计数原子,但内容可被多线程共享也可被析构线程移动 |
| Mutex | T: Send | T: Send | 锁把 !Sync 提升为 Sync |
| MutexGuard<'_, T> | 否 | T: Sync | 某些平台要求加锁/解锁在同一线程 |
此外,还有一种特殊情景,就是你的类型被 auto trait 自动判定为了 Send + Sync,但是出于某种原因,你不希望这个类型应用到多线程中,你就可以使用 PhantomData 去包裹一个裸指针作为你的类型中的一个字段,就像下面这样:
use std::marker::PhantomData;
struct NotSend {
_marker: PhantomData<*mut ()>, // 零大小,没有运行时存储
}
// NotSend: !Send + !Sync,虽然没有实际裸指针成员
这个方法的思路是:因为你包裹了 *mut() 这样一个裸指针,auto trait 就会自动把这个类型标记为 !Send + !Sync,使用 PhantomData 包裹的目的是把这个字段的开销降为 0,不占用任何内存。
6. 为什么其他语言里没有 Send / Sync
最后这我们回答一个很多人可能在接触 Send 和 Sync 之初就思考过的问题:为什么其他语言里没有 Send 和 Sync?在理解了以上的内容之后,这个问题已经很好解释了。我们说,针对数据竞争这个问题来说,Rust 凭借它的所有权和借用检查机制“几乎”可以直接规避掉了,但是,由于裸指针、UnsafeCell 等一些特殊机制可以打开“后门”,让一些类型在多线程环境变得不安全了,所以 Rust 才引入了 Send 和 Sync 对类型的线程安全性进行标记。而在其他语言里,一个类型在多线程环境中是否会成为“被竞争的数据”并不取决于类型自身,因为没有所有权和借用检查机制的加持,类型自己根本左右不了这件事,只能靠写程序的人来保证:要么不会写出在多线程下既读又写的代码,要么给变量添加同步保护。
如果你对此表示怀疑,可以参考《一个 Rust 多线程的笼统问题:一个值和它的引用交叉分布于不同线程中可以有多少种情况?哪些是合法的?》 ↩︎
通过 Rc::get_mut 可以拿到 Option<&mut T>,但是它会检查 strong == 1 && weak == 1;通过 Rc::try_unwrap 可以拿到 Result<T, Rc<T>>,但是它会检查 strong == 1,这些操作都是要求只剩一个 Rc 实例时才可以操作,以在多个 Rc 并存的场景下,每个线程能拿到的只有 &T。 ↩︎
网硕互联帮助中心


评论前必须登录!
注册