Atomic 类型用于包装一个整数,保证对这个整数的操作是原子的,并且可以指定内存顺序。用于多线程场景。
如果多个线程同时持有一个普通整数,并且至少有一个线程执行写操作,那么就会有 data race 即 UB 的风险。举几个例子:
- 两个线程都进行 x += 1,这是一个典型的读改写,由于整个操作非原子,两次修改可能基于同一个原始值,最终 x 的值并没有增加 2.
- 一个线程等待 x != 0 发生再退出循环,另一个线程修改 x。由于编译器不知道 x 是一个多线程访问的变量,可能将 x != 0 优化为 tmp = x, tmp != 0。此时另一个线程修改 x 不会影响 tmp 的值,导致死循环。
- 两个线程同时执行 x = 1 和 print(x),由于没有同步要求,编译器可能从硬件指令上对这些操作做各种优化,导致最终 print(x) 打印的值并非原始值或 1,出现了 UB。
也就是说,即使我们不要求读改写原子,只要求简单的赋值操作完整、跨线程可见,我们也需要使用 Atomic。否则编译器可能在"无需同步"的假设下,做出各种优化,导致 UB。
Atomic 类型提供一系列 API,如 fetch_add 等。在编译这些 API 时,编译器不会随意优化,可能还会使用特殊的硬件指令保证写入结果同步到各个 CPU 上。(这里可能涉及缓存一致性协议)从而避免了上面提到的可能出现的 UB。
如果在实际编程时不好想象使用简单的整数类型会引发什么问题,可以选择 坚定的相信 在"多个线程同时持有一个普通整数,并且至少有一个线程执行写操作" 的场景下,一定要使用 Atomic。
Ordering
fetch_add 等 API 中有一个参数叫 Ordering,他是用于限制访存操作的内存顺序的。因为默认情况下,CPU 和 编译器 可能进行指令重排序的优化,即代码中编写的变量赋值顺序(单个线程中)可能与实际发生的顺序不同。
举一个例子
rust
// 线程 1
data = 42;
ready.store(true, Release);
// 线程 2
if ready.load(Acquire) {
println!("{}", data);
}
通过使用 Release / Acquire 保证:store(true) 发生时,前面的 data 一定被赋值为了 42;load 发生后,线程 2 才会去获取 data 的值。如果这两个有一个没有得到保证,就可能发生 ready 是 true 了,但 data 不是 42 的情况。
所以 Release 和 Acquire 的语义是这样的
- Release:适用于写操作,要求此次写操作之前的内存访问一定都完成了,再执行写操作。
- Acquire:适用于读操作,要求此次读操作完成后,再进行后续的内存访问操作。
Release / Acquire 必须成对、对同一个变量使用,才能达成在线程间同步的效果。
其他的 Ordering 还有 Relaxed、AcqRel、SeqCst 等,就不一一赘述了。