基本认知

  1. Mutex 用起来简单,但是无法并发读RwLock 可以并发读,但是使用场景较为受限且性能不够,那么有没有一种全能性选手呢?欢迎 Atomic 闪亮登场。从 Rust 1.34 版本后,就正式支持原子类型。原子指的是一系列不可被CPU上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核CPU下,当某个CPU核心开始运行原子操作时,会先暂停其它CPU内核对内存的操作,以保证原子操作不会被其它CPU内核所干扰
  2. 由于原子操作通过指令提供的支持,它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改和读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型
  3. 可以看出原子类型无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了 CAS 循环,当大量的冲突发生时,该等待还是得等待!但是总归比锁要好。CAS 全称是 Compare and swap,它通过一条指令读取指定的内存地址,然后判断其中的值是否等于给定的前置值,如果相等,则将其修改为新的值
  4. std::sync::atomic 模块包含无锁并发编程要使用的原子类型。这些类型基本上与标准 C++ 原子类型相同
    1. AtomicIsize 和 AtomicUsize 是共享的整数类型,对应单线程的 isize 和 usize 类型
    2. AtomicBool 是一个共享的 bool 值
    3. AtomicPtr 是不安全指针类型 *mut T 的共享值

基本示例

关于如何正确使用原子数据超出了本书范围。一言以蔽之,就是多线程可以同时读取原子值但不会导致数据争用。与常规算术和逻辑操作符不同,原子类型暴露了执行原子操作的方法,涉及个别值的加载、存储、交换和算术操作,作为一个单元执行,而且即使其他线程也对同一块内存执行原子操作仍能保证安全。比如,下面的代码会递增名为 atom 的 AtomicIsize

线程同步:共享内存(原子) - 图1

这些方法可以编译为特殊的机器语言指令。在 x86-64 架构上, .fetch_add() 调用编译为 lock incq 指令,其中普通的 n += 1 可以编译为纯 incq 指令或相关的其他任何变体。 Rust 编译器同样必须放弃对原子操作的某些优化,因为(与正常加载、存储不同)其他线程可以理所当然地立即观察到。原子的一个最简单应用就是取消操作。假设有一个线程正在执行某个耗时的计算任务,比如渲染视频,而我们希望能够异步取消这个操作。问题在于如何希望与其停止工作的线程通信。可以通过一个共享的 AtomicBool 来实现

线程同步:共享内存(原子) - 图2

以上代码创建了两个 Arc 智能指针,都指向分配在堆上的 AtomicBool ,其初始值是 false。第一个名为 cancel_flag ,会保留在主线程。第二个名为 worker_cancel_flag ,会被转移到工作线程。下面就是工作线程相关的代码

线程同步:共享内存(原子) - 图3

渲染完每个像素,线程都会调用 .load() 方法检查取消标志的值。如果主线程决定取消工作线程的工作,就可以把 true 保存到 AtomicBool ,然后等着线程自己退出

线程同步:共享内存(原子) - 图4

当然,还有其他实现方式。比如,可以用 Mutex通道 来代替这里的 AtomicBool 。主要区别在于原子的开销最小。原子操作永远不使用系统调用。加载和存储经常编译为一个 CPU 指令。原子是一种内部修改能力,与 Mutex 或 RwLock 类似,因此它们的方法也以 self 的共享(非 mut )引用为参数。而这也让它们可以作为简单的全局变量来使用

使用 Atomic 的场景

事实上,Atomic 虽然对于用户不太常用,但是对于高性能库的开发者、标准库的开发者都非常常用,它是并发原语的基石,除此之外,还有一些场景适用

  • 无锁(lock free)数据结构
  • 全局变量,例如全局自增ID
  • 跨线程计数器,例如可以用于统计指标

以上列出的只是 Atomic 适用的部分场景,具体场景需要未来根据自己的需求进行权衡选择。原子类型的一个常用场景,就是作为全局变量来使用

线程同步:共享内存(原子) - 图5

以上代码启动了数个线程,每个线程都在疯狂对全局变量进行加1操作,最后将它与 线程数*加1次数 进行比较,如果发生了因为多个线程同时修改导致了脏数据,那么这两个必将不相等。好在它没有让我们失望,不仅快速的完成了任务,而且保证了100%的并发安全性。当然以上代码功能可以通过 Mutex 来实现,但是后者的强大功能是建立在额外的性能损耗基础上的,因此性能会逊色不少

线程同步:共享内存(原子) - 图6

还有一点值得注意,和 Mutex 一样, Atomic 的值具有内部可变性,你无需将其声明为 mut

内存顺序

基本概述

参数 Ordering::SeqCst 是一个 内存顺序(memory ordering)。 内存排序类似于数据库中的一个事务隔离层。内存排序对程序执行是否正确至关重要,但也非常不好理解和推断。不过令人高兴的是,选择顺序一致性(最严格的内存排序)的性能损失通常很低。这跟把 SQL 数据库放到 SERIALIZABLE 模式下的性能损失不可同日而语。因此如果不敢肯定,那就用 Ordering::SeqCst 。 Rust 从标准 C++ 原子类型继承了其他几种内存排序,对存在和时间也有不同程度的弱化保证。内存顺序是指 CPU 在访问内存时的顺序,该顺序可能受以下因素的影响

  • 代码中的先后顺序
  • 编译器优化导致在编译阶段发生改变(内存重排序 reodering )
  • 运行阶段因 CPU 的缓存机制导致顺序被打乱

编译器优化导致内存顺序的改变

对于第2点,举个例子

线程同步:共享内存(原子) - 图7

假如在 C 和 D 代码片段中,根本没有用到 x = 1,那么编译器很可能会将 x = 1 和 x = 2 进行合并

线程同步:共享内存(原子) - 图8

若代码 A 中创建了一个新的线程用于读取全局静态变量 X,则该线程将无法读取到 x = 1 的结果,因为在编译阶段就已经被优化掉

CPU 缓存导致的内存顺序的改变

线程同步:共享内存(原子) - 图9

线程同步:共享内存(原子) - 图10

内存顺序的 5 个规则

线程同步:共享内存(原子) - 图11

内存屏障的例子

下面我们以 ReleaseAcquire 为例,使用它们构筑出一对内存屏障,防止 编译器和CPU 屏障前(Release)屏障后(Acquire) 中的数据操作重新排在屏障围成的范围之外

  1. use std::thread::{self, JoinHandle};
  2. use std::sync::atomic::{Ordering, AtomicBool};
  3. static mut DATA: U64 = 0;
  4. static READY: AtomicBool = AtomicBool::new(false);
  5. fn reset() {
  6. unsafe {
  7. DATA = 0;
  8. }
  9. READY.store(false, Ordering::Relaxed);
  10. }
  11. fn producer() -> JoinHandle<()> {
  12. thread::spawn(move || {
  13. unsafe {
  14. DATA = 100; // A
  15. }
  16. READY.store(true, Ordering::Release); // B: 内存屏障 ↑
  17. });
  18. }
  19. fn consumer() -> JoinHandle<()> {
  20. thread::spawn(move || {
  21. while !READY.load(Ordering::Acquire) {} // C: 内存屏障 ↓
  22. assert_eq!(100, unsafe { DATA }); // D
  23. });
  24. }
  25. fn main() {
  26. loop {
  27. reset();
  28. let t_producer = producer();
  29. let t_consumer = consumer();
  30. t_producer.join().unwrap();
  31. t_consumer.join().unwrap();
  32. }
  33. }

原则上,Acquire 用于读取,而 Release 用于写入。但是由于有些原子操作同时拥有读取和写入的功能,此时就需要适用 AcqRel 来设置内存顺序了。在内存屏障中被写入的数据,都可以被其他线程读取到,不会有 CPU 缓存的问题。内存顺序的选择有如下经验

  • 不知道怎么选择时,优先使用 SeqCst ,虽然会稍微减慢速度,但是慢一点也比出现错误好
  • 多线程只计数 fetch_add 而不使用该值触发其他逻辑分支的简单使用场景,可以使用 Relaxed

https://stackoverflow.com/questions/30407121/which-stdsyncatomicordering-to-use

多线程中使用 Atomic

线程同步:共享内存(原子) - 图12

Atomic 能替代锁吗

那么原子类型这么全能,它可以替代锁吗?答案是不行

  • 对于复杂的场景下,锁的使用简单粗暴,不容易有坑
  • std::sync::atomic 包中仅提供了数值类型的原子操作( AtomicBool、AtomicUsize、AtomicI8 等),而锁可以应用于各种类型
  • 有些情况下必须使用锁来配合,例如使用 Mutex 配合 Condvar