内存排序
原文链接: https://marabos.nl/atomics/memory-ordering.html.
英文: Memory Ordering.
编译器和处理器会对代码进行优化, 前者可能在编译时对指令进行重排序, 而后者可能在程序运行时对命令进行乱序执行 (out-of-order execution, OoOE).
以下面函数为例:
由于 a
和 b
的类型均为可变参数 (&mut i32
), 因此在它们的生命周期内, 没有线程的代码可以读写它们的值.
编译器可以自由的优化指令, 比如将两个 *a += 1;
语句结合为单个 *a += 2;
语句, 以提高代码的运行效率, 同时保持执行结果的不变.
而原子类型没有 mut
约束, 不受这种保护. 所以需要在进行原子操作时, 指定内存排序, 来告知编译器可以进行何种程度的调整, 以便在优化代码的同时, 保持执行结果的不变.
可用的内存排序通过枚举体 std::sync::atomic::Ordering
表示, 分别为:
Ordering::Relaxed
.Ordering::{Release, Acquire, AcqRel}
.Ordering::SeqCst
.
先行发生关系 (Happens-Before Relationship)
这里需要详细说明一下 图 3-1 的执行结果为什么可能是 0 20
. 从这个结果来看, 3 依赖了 2 的执行结果, 而 1 与 2 有存在先行发生关系, 所以 "1 的执行早于 3, 即执行顺序为 1 -> 2 -> 3 -> 4". 但这个结论并不正确, 因为先行发生关系是仅对 a 线程来说可见的, 可能对 b 线程不可见. 关于可见性的问题下面将进行简单说明.
值得主意的是, 例子中的 1 与 2 操作的是不同的变量 (X
与 Y
), 如果 1 和 2 操作的是同一个变量, 那么这种先行发生关系将是所有线程可见的. 即可以线程 b 可以通过原子变量的值推断线程 a 中语句的执行顺序.
由于本人对计算机硬件的理解有限, 我猜测可能的原因有:
- 1 先于 2 执行: 1 执行后的结果对于线程 a (即 2) 来说是可见的, 而可能对与线程 b (即 4) 来说是不可见的 (缓存未同步1). 从读取了过时数据的线程 b 的视角来看, 线程 a 的执行顺序恰恰相反.
- 2 先于 1 执行 (重排序): 因为 1 与 2 操作的是不同的变量, 因此即使重新排序, 看上去最终执行结果与 1 先于 2 执行一样, 所以依然满足 1 "happens-before" 2.
总之, 现代计算机的实现十分复杂, 内存排序 Ordering::Relaxed
提供唯一的保证就是对于线程 a 来说, 1 先于 2 执行, 而对于线程 b 来说, 3 先于 4 执行.
除了在执行原子操作时手动指定内存排序, 线程的生成 (spawn) 与加入 (join) 也会产生跨越线程的先行发生关系.
Relaxed 排序
这是最宽松的内存排序, 只确保当前线程内, 同一个原子变量之间的操作是有序执行的.
所以即使例子中的两个线程之间不存在直接的先行发生关系, 依然能确定 X.load()
的返回值变化一定满足 0
-> 5
-> 15
.
Release 和 Acquire 排序
Release
仅用于 store
操作, 而 Acquire
仅用于 load
操作. 当他们操作于同一变量时, 将建立先行发生关系.
一种简单的理解方式是, 编译器和处理器只对 Release
之前的操作做重排序, 而只对 Acquire
之后的操作做重排序.
这样就能确保 Acquire
之后的代码对 Release
之前的操作的可见的.
这种内存排序确实不仅适用于原子变量, 还能确保在 Release 前对其他非原子变量 (如全局静态可变变量) 的修改结果对 Acquire
后的代码可见.
Sequentially Consistent 排序
这是最严格的排序.
栅栏 (Fences)
可以通过与原子操作进行对比来快速了解内存栅栏的使用方法:
单纯的使用栅栏来替代原子操作会稍微低效, 因为需要额外的处理器指令.
但由于栅栏更加灵活, 因此条件性的使用栅栏可能比直接使用更严格内存排序的原子操作更加高效. 比如:
使用原子操作的实现:
let p = PTR.load(Acquire);
if p.is_null() {
println!("no data");
} else {
println!("data = {}", unsafe { *p });
}
使用栅栏的实现:
let p = PTR.load(Relaxed); // 使用更宽松的内存排序
if p.is_null() {
println!("no data");
} else {
fence(Acquire); // 仅当需要通过指针访问数据时, 才需要建立先行发生关系, 以确保数据已经完成初始化
println!("data = {}", unsafe { *p });
}
-
现代多核 CPU 与主存直接存在多级缓存, 通常为 3 级, 分别被称之为 L1, L2 和 L3. 其中 L3 由所有核心共享, 而每个核心都有自己的 L1 和 L2. 缓存之间不及时的同步就会导致可见性问题. ↩