玩转Rust高级应用 如何理解 Rust 实现免疫数据竞争的关键是Send 和 Sync 这两个 trait

有很多人尝试过很多办法,来从根源上解决数据竞争(Data race) 的问题。根据数据竞 争的定义,它的发生需要三个条件:

  • 数据共享------有多个线程同时访问一份数据;
  • 数据修改------至少存在一个线程对数据做修改;
  • 没有同步------至少存在一个线程对数据的访问没有使用同步措施。

我们只要让这三个条件无法同时发生即可:

  • 可以禁止数据共享,比如 actor-based concurrency, 多线程之间的通信仅靠发送消息来 实现,而不是通过共享数据来实现;
  • 可以禁止数据修改,比如 functional programming, 许多函数式编程语言严格限制了数 据的可变性,而对共享性没有限制。

然而以上设计在许多情况下都有一些性能上的缺陷,无法达到"零开销抽象"的目的。

Rust 并没有盲目跟随传统语言的脚步设计。Rust 允许存在可变变量,允许存在状态共享, 同时也做到了完整无遗漏的线程安全检查。因为Rust 设计的一个核心思想就是"共享不可 变,可变不共享",然后再加上类型系统和合理的API 设计,就可以保证共享数据在访问时 一定使用了同步措施。Rust 既可以支持多线程数据共享的风格,也可以支持消息通信的风格。 无论选择哪种方案,编译器都能保证没有数据竞争。

请注意这个区别:我们不是说传统C/C++ 就无法做到线程安全,而是说,传统C/C++ 需要依赖程序员不犯错误来保证线程安全;而Rust 是由工具自动保证的,这个保证更稳定、 更可靠、更有底气。虽然C/C++ 里面也有许多静态检查工具,可以辅助我们自动发现一些线 程安全问题,但是由于C/C++ 灵活度太大、自由度太高,因此可以肯定的是没有任何一款静 态检查工具可以保证百分百"无遗漏、无误报"的线程安全检查。

现在没有,将来也不可能 有。所以不得不依赖程序员的水平。在代码规模大到一定程度之后,这种做法是不可靠的, 不论一个人实力有多强,总有马虎的时候、疲惫的时候、情绪不良的时候,偶尔犯错是不可 避免的,更何况大规模的团队存在管理配合问题、人员流动交接问题等, 一不小心就会埋下 一个隐患。作为对比, Rust 对线程的安全检查是稳定的、可靠的,不因时因地而有所波动, 不因代码量的多少或复杂程度而懈怠。这个特点,对于某些对安全性要求很高的场景具有非 同寻常的意义。

这个区别赋予了Rust 一种特殊的能力,它使Rust 的使用者有了更强大的自信,让Rust的使用者有胆量使用更激进的并行优化。


Send&Sync

下面来简单讲解一下Rust 是如何实现免疫数据竞争的。Rust 线程安全背后的功臣是两 个特殊的trait。

  • std::marker::Sync如果类型T 实现了Sync 类型,那说明在不同的线程中使用&T 访问同一个变量是安 全的。
  • std::marker::Send如果类型T 实现了Send 类型,那说明这个类型的变量在不同的线程中传递所有权是安 全的。

Rust 把类型根据Sync 和Send 做了分类。这样做起什么作用呢?当然是用在"泛型 约束"中。Rust 中所有跟多线程有关的API, 会根据情况,要求类型必须满足Sync 或者 Send 的约束。这样一来,"孙猴子就永远也逃不出如来佛的手掌心"了。你不可能随意在多 线程之间共享变量,也不可能在使用多线程共享的时候忘记加锁。除非你使用unsafe,否 则不可能写出存在"数据竞争"的代码来。

比如我们最常见的创建线程的函数spawn, 它的完整函数签名是这样的:

js 复制代码
pub fn spawn < F,
T > (f: F) - >JoinHandle < T > where F: FnOnce() - >T,
F: Send + 'static,T:Send +'static

我们需要注意的是,参数类型F 有重要的约束条件F:Send +'static,T:Send +'static。 但凡在线程之间传递所有权会发生安全问题的类型,都无法在这个参数中出现, 否则就是编译错误。另外,Rust 对全局变量也有很多限制,你不可能简单地通过全局变量在 多线程中随意共享状态。这样,编译器就会禁止掉可能有危险的线程间共享数据的行为。

在Rust 中,线程安全是默认行为,大部分类型在单线程中是可以随意共享的,但是没办 法直接在多线程中共享。也就是说,只要程序员不滥用unsafe,Rust编译器就可以检查出 所有具有"数据竞争"潜在风险的代码。凡是通过了编译检查的代码,Rust可以保证,绝对 不会出现"线程不安全"的行为。如此一来,多线程代码和单线程代码就有了严格的分野。 一般情况下,我们不需要考虑多线程的问题。即便是万一不小心在多线程中访问了原本只设 计为单线程使用的代码,编译器也会报错。


详解 Send 和Sync

在上文中我们已经提到, Rust 实现免疫数据竞争的关键是Send 和 Sync 这两个 trait。 那么这两个trait 究竟表达了什么意思?它们背后是什么原理?我们在本章详细分析。

什么是Send

根据定义:如果类型T 实现了Send trait,那说明这个类型的变量在不同线程中传递所 有权是安全的。但这句话对于初学者并不是那么容易理解的。究竟具备什么特点的类型才满 足 Send 约束?本节就来详细分析一下。

如果一个类型可以安全地从一个线程move 进入另一个线程,那它就是Send 类型。比 如:普通的数字类型是Send, 因为我们把数字move 进入另一个线程之后,两个线程同时执 行也不会造成什么安全问题。

更进一步,内部不包含引用的类型,都是Send 。因为这样的类型跟外界没有什么关联, 当它被move 进入另一个线程之后,它所有的部分都跟原来的线程没什么关系了,不会出现 并发访问的情况。比如 String 类型。

稍微复杂一点的,具有泛型参数的类型,是否满足Send 大多是取决于参数类型是否 满足Send 。 比如Vec, 只要我们能保证T:Send, 那 么Vec 肯定也是Send, 把它 move 进入其他线程是没什么问题的。再比如Cell 、RefCell 、Option、 Box, 也都是这种情况。

还有一些类型,不论泛型参数是否满足Send, 都是满足Send 的。这种类型,可以看作 一种"构造器",把不满足Send 条件的类型用它包起来,就变成了满足Send 条件的类型。 比如Mutex 就是这种。Mutex 这个类型实际上不关心它内部类型是怎样的,反正要 访问内部数据, 一定要调用lock() 方法上锁,它的所有权在哪个线程中并不重要,所以把它 move 到其他线程也是没有问题的。

那么什么样的类型是!Send 呢?典型的如RC 类型。我们知道,Rc 是引用计数指 针,把Rc 类型的变量move 进入另外一个线程,只是其中一个引用计数指针move 到了其他 线程,这样会导致不同的线程中的Rc 变量引用同一块数据,Rc内部实现没有做任何线程同 步处理,这是肯定有问题的。所以标准库中早已指定Rc 是 !Send 。 当我们试图在线程边界 传递这个类型的时候,就会出现编译错误。

但是相对的是,Arc 类型是符合Send 的(当然需要T:Send) 。 为什么呢?因为Arc 类型内部的引用计数用的是"原子计数",对它进行增减操作,不会出现多线程数据竞争。 所以,多个线程拥有指向同一个变量的Arc 指针是可以接受的。


什么是Sync

对应的,Sync 的定义是,如果类型T 实现了Sync trait, 那说明在不同的线程中使用&T 访问同一个变量是安全的。这句话也不好理解。下面我们仔细分析一下哪些类型是满足Sync 约束的。

显然,基本数字类型肯定是Sync 。假如不同线程都拥有指向同一个i32 类型的只读引 用 &i32, 这是没什么问题的。因为这个类型引用只能读,不能写。多个线程读同一个整数 是安全的。

大部分具有泛型参数的类型是否满足 Sync, 很多都是取决于参数类型是否满足Sync。 像 Box 、VecOption 这种也是Sync 的,只要其中的参数T 是满足Sync 的。

也有一些类型,不论泛型参数是否满足Sync, 它都是满足 Sync 的。这种类型把不满足 Sync 条件的类型用它包起来,就变成了满足Sync 条件的。Mutex 就是这种。多个线程 同时拥有&Mutex 型引用,指向同一个变量是没问题的。

那么什么样的类型是!Sync 呢?所有具有"内部可变性"而又没有多线程同步考虑的 类型都不是Sync 的。比如,Cell 和 RefCell 就不能是Sync 的。按照定义,如果 我们多个线程中都持有指向同一个变量的&Cell 型指针,那么在多个线程中,都可以执 行Cell::set 方法来修改它里面的数据。而我们知道,这个方法在修改内部数据的时候, 是没有考虑多线程同步问题的。所以,我们必须把它标记为!Sync。

还有一些特殊的类型,它们既具备内部可变性,又满足 Sync 约束,比如前面提到的 Mutex 类型。为什么说Mutex 具备内部可变性?大家查一下文档就会知道,这个 类型可以通过不可变引用调用lock() 方法,返回一个智能指针MutexGuard 类型, 而这个智能指针有权修改内部数据。这个做法就跟RefCell 的 try_borrow_mut()方法非常类似。区别只是:Mutex::lock() 方法的实现,使用了操作系统提供的多线程同 步机制,实现了线程同步,保证了异步安全;而RefCell 的内部实现就是简单的普通数字 加减操作。因此,Mutex 既具备内部可变性,又满足 Sync 约束。除了Mutex, 准 库 中 还 有RwLock 、AtomicBool 、AtomicIsize 、AtomicUsize 、AtomicPtr 等类型,都提供了内部可变性,而且满足Sync 约束。

相关推荐
Blossom.1181 小时前
AI Agent记忆系统深度实现:从短期记忆到长期人格的演进
人工智能·python·深度学习·算法·决策树·机器学习·copilot
云和数据.ChenGuang2 小时前
Python 3.14 与 PyCharm 2025.2.1 的调试器(PyDev)存在兼容性问题
开发语言·python·pycharm
Q741_1472 小时前
C++ 面试高频考点 链表 迭代 递归 力扣 25. K 个一组翻转链表 每日一题 题解
c++·算法·链表·面试·递归·迭代
全栈小52 小时前
【Rust】从0到1开发和运行Web相关功能,并简单实现数据库连接和查询
数据库·rust
Mr.Jessy2 小时前
Web APIs 学习第六天:BOM、location对象与本地存储
开发语言·前端·javascript·学习·web api·bom
_fairyland2 小时前
数据结构 力扣 练习
数据结构·考研·算法·leetcode
LIZhang20162 小时前
基于ffmpeg8.0录制mp4文件
开发语言·c++
Neil今天也要学习2 小时前
永磁同步电机无速度算法--基于三阶LESO的反电动势观测器
算法·1024程序员节
_OP_CHEN3 小时前
C++进阶:(九)深度剖析unordered_map 与 unordered_set容器
开发语言·c++·stl容器·哈希表·哈希桶·unordered_map·unordered_set