基本认知
- Mutex 用起来简单,但是无法并发读; RwLock 可以并发读,但是使用场景较为受限且性能不够,那么有没有一种全能性选手呢?欢迎 Atomic 闪亮登场。从 Rust 1.34 版本后,就正式支持原子类型。原子指的是一系列不可被CPU上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核CPU下,当某个CPU核心开始运行原子操作时,会先暂停其它CPU内核对内存的操作,以保证原子操作不会被其它CPU内核所干扰
- 由于原子操作是通过指令提供的支持,它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改和读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型
- 可以看出原子类型是无锁类型,但是无锁不代表无需等待,因为原子类型内部使用了 CAS 循环,当大量的冲突发生时,该等待还是得等待!但是总归比锁要好。CAS 全称是 Compare and swap,它通过一条指令读取指定的内存地址,然后判断其中的值是否等于给定的前置值,如果相等,则将其修改为新的值
- std::sync::atomic 模块包含无锁并发编程要使用的原子类型。这些类型基本上与标准 C++ 原子类型相同
- AtomicIsize 和 AtomicUsize 是共享的整数类型,对应单线程的 isize 和 usize 类型
- AtomicBool 是一个共享的 bool 值
- AtomicPtr
是不安全指针类型 *mut T 的共享值
基本示例
关于如何正确使用原子数据超出了本书范围。一言以蔽之,就是多线程可以同时读取原子值但不会导致数据争用。与常规算术和逻辑操作符不同,原子类型暴露了执行原子操作的方法,涉及个别值的加载、存储、交换和算术操作,作为一个单元执行,而且即使其他线程也对同一块内存执行原子操作仍能保证安全。比如,下面的代码会递增名为 atom 的 AtomicIsize
这些方法可以编译为特殊的机器语言指令。在 x86-64 架构上, .fetch_add() 调用编译为 lock incq 指令,其中普通的 n += 1 可以编译为纯 incq 指令或相关的其他任何变体。 Rust 编译器同样必须放弃对原子操作的某些优化,因为(与正常加载、存储不同)其他线程可以理所当然地立即观察到。原子的一个最简单应用就是取消操作。假设有一个线程正在执行某个耗时的计算任务,比如渲染视频,而我们希望能够异步取消这个操作。问题在于如何希望与其停止工作的线程通信。可以通过一个共享的 AtomicBool 来实现
以上代码创建了两个 Arc
渲染完每个像素,线程都会调用 .load() 方法检查取消标志的值。如果主线程决定取消工作线程的工作,就可以把 true 保存到 AtomicBool ,然后等着线程自己退出
当然,还有其他实现方式。比如,可以用 Mutex
使用 Atomic 的场景
事实上,Atomic 虽然对于用户不太常用,但是对于高性能库的开发者、标准库的开发者都非常常用,它是并发原语的基石,除此之外,还有一些场景适用
- 无锁(lock free)数据结构
- 全局变量,例如全局自增ID
- 跨线程计数器,例如可以用于统计指标
以上列出的只是 Atomic 适用的部分场景,具体场景需要未来根据自己的需求进行权衡选择。原子类型的一个常用场景,就是作为全局变量来使用
以上代码启动了数个线程,每个线程都在疯狂对全局变量进行加1操作,最后将它与 线程数*加1次数 进行比较,如果发生了因为多个线程同时修改导致了脏数据,那么这两个必将不相等。好在它没有让我们失望,不仅快速的完成了任务,而且保证了100%的并发安全性。当然以上代码功能可以通过 Mutex 来实现,但是后者的强大功能是建立在额外的性能损耗基础上的,因此性能会逊色不少
还有一点值得注意,和 Mutex 一样, Atomic 的值具有内部可变性,你无需将其声明为 mut
内存顺序
基本概述
参数 Ordering::SeqCst 是一个 内存顺序(memory ordering)。 内存排序类似于数据库中的一个事务隔离层。内存排序对程序执行是否正确至关重要,但也非常不好理解和推断。不过令人高兴的是,选择顺序一致性(最严格的内存排序)的性能损失通常很低。这跟把 SQL 数据库放到 SERIALIZABLE 模式下的性能损失不可同日而语。因此如果不敢肯定,那就用 Ordering::SeqCst 。 Rust 从标准 C++ 原子类型继承了其他几种内存排序,对存在和时间也有不同程度的弱化保证。内存顺序是指 CPU 在访问内存时的顺序,该顺序可能受以下因素的影响
- 代码中的先后顺序
- 编译器优化导致在编译阶段发生改变(内存重排序 reodering )
- 运行阶段因 CPU 的缓存机制导致顺序被打乱
编译器优化导致内存顺序的改变
对于第2点,举个例子
假如在 C 和 D 代码片段中,根本没有用到 x = 1,那么编译器很可能会将 x = 1 和 x = 2 进行合并
若代码 A 中创建了一个新的线程用于读取全局静态变量 X,则该线程将无法读取到 x = 1 的结果,因为在编译阶段就已经被优化掉
CPU 缓存导致的内存顺序的改变
内存顺序的 5 个规则
内存屏障的例子
下面我们以 Release 和 Acquire 为例,使用它们构筑出一对内存屏障,防止 编译器和CPU 将 屏障前(Release) 和 屏障后(Acquire) 中的数据操作重新排在屏障围成的范围之外
use std::thread::{self, JoinHandle};
use std::sync::atomic::{Ordering, AtomicBool};
static mut DATA: U64 = 0;
static READY: AtomicBool = AtomicBool::new(false);
fn reset() {
unsafe {
DATA = 0;
}
READY.store(false, Ordering::Relaxed);
}
fn producer() -> JoinHandle<()> {
thread::spawn(move || {
unsafe {
DATA = 100; // A
}
READY.store(true, Ordering::Release); // B: 内存屏障 ↑
});
}
fn consumer() -> JoinHandle<()> {
thread::spawn(move || {
while !READY.load(Ordering::Acquire) {} // C: 内存屏障 ↓
assert_eq!(100, unsafe { DATA }); // D
});
}
fn main() {
loop {
reset();
let t_producer = producer();
let t_consumer = consumer();
t_producer.join().unwrap();
t_consumer.join().unwrap();
}
}
原则上,Acquire 用于读取,而 Release 用于写入。但是由于有些原子操作同时拥有读取和写入的功能,此时就需要适用 AcqRel 来设置内存顺序了。在内存屏障中被写入的数据,都可以被其他线程读取到,不会有 CPU 缓存的问题。内存顺序的选择有如下经验
- 不知道怎么选择时,优先使用 SeqCst ,虽然会稍微减慢速度,但是慢一点也比出现错误好
- 多线程只计数 fetch_add 而不使用该值触发其他逻辑分支的简单使用场景,可以使用 Relaxed
https://stackoverflow.com/questions/30407121/which-stdsyncatomicordering-to-use
多线程中使用 Atomic
Atomic 能替代锁吗
那么原子类型这么全能,它可以替代锁吗?答案是不行
- 对于复杂的场景下,锁的使用简单粗暴,不容易有坑
- std::sync::atomic 包中仅提供了数值类型的原子操作( AtomicBool、AtomicUsize、AtomicI8 等),而锁可以应用于各种类型
- 有些情况下必须使用锁来配合,例如使用 Mutex 配合 Condvar