内存一致性
好的,我们来深入、详细地解析 ARM 架构中的内存一致性(Memory Consistency)。这是一个比缓存一致性更微妙、对程序员影响更直接的概念。
ARM 架构的内存模型是其最显著的特点之一,也是与 x86 等架构最大的不同之处。
核心概念:弱内存排序模型
ARM 采用 Weakly-ordered 或 Relaxed 内存模型。这是理解其所有行为的基础。
- 核心原则: 在弱内存模型中,除非使用明确的屏障指令,否则处理器和编译器可以对内存访问操作(读/写)进行重排序,只要这种重排序在单线程上下文中不会改变程序的执行结果。
- 目标: 最大化性能。重排序可以充分利用总线带宽、隐藏内存访问延迟、提高指令级并行度。
这与 x86 的 Strongly-ordered 模型形成鲜明对比。在 x86 上,绝大多数内存操作会保持程序序(Program Order),硬件替你做了很多保序工作,但这牺牲了一定的性能。ARM 则选择将保序的责任更多地交给软件,以换取更高的能效比——这对移动设备至关重要。
为什么会发生重排序?
重排序来自两个层面:
- 编译器重排: 编译器在编译代码时,为了优化,可能会调整指令的顺序。
- 硬件重排: 处理器在执行时,可能会:
- 乱序执行(Out-of-Order Execution): 后面的指令如果没有依赖前面的指令,可能会先执行。
- 写缓冲(Store Buffer): 当一个核心执行写操作时,它不会傻等写操作完成并通知所有其他核心,而是会先把数据放入一个本地的写缓冲区,然后继续执行后续指令。这个写缓冲区中的数据会异步地刷入缓存和内存。这导致一个核心看到自己写的顺序和另一个核心看到这个核心的写的顺序可能不同。
一个经典的例子:为什么需要屏障
让我们看一个在弱内存模型下会出错的经典例子:
// Initial values: data = 0, flag = 0
// CPU 0 (Producer) // CPU 1 (Consumer)
data = 42; while (flag == 0) { /* spin */ }
flag = 1; print(data);
直觉期望: CPU1 在循环中直到看到 flag 变为 1,然后打印 data。由于 data = 42 在 flag = 1 之前执行,我们期望打印出 42。
在 ARM 弱模型下的可能执行序列:
- CPU0 将
data = 42放入它的写缓冲区。这个操作比较慢,因为它需要获取缓存行的独占权(可能涉及使其他缓存的副本失效)。 - CPU0 继续执行,
flag = 1操作可能更快完成(比如flag的缓存行正好处于独占状态),于是这个写操作先于data = 42被提交到缓存系统中,对其他核心可见。 - CPU1 一直在监听总线,它看到了
flag变成了 1,于是跳出循环。 - CPU1 读取
data,但此时 CPU0 写缓冲区里的data = 42还没有刷出,所以 CPU1 读到了旧值0。
结果: CPU1 打印出了 0,而不是预期的 42。
问题根源: 两个存储操作(data=42 和 flag=1)被重排序了。从 CPU1 的视角看,CPU0 的操作顺序变成了 flag=1 然后 data=42。
ARM 的解决方案:内存屏障指令
为了解决这个问题,ARM 提供了一组明确的内存屏障指令,让程序员和系统软件开发者可以在代码中强制指定内存操作的顺序。这是保证 ARM 内存一致性的关键。
主要屏障指令如下:
1. DMB (Data Memory Barrier)
- 作用: 确保在 DMB 指令之前的所有内存访问(读和写)都完成后,才开始执行在它之后的内存访问。
- 类比: “所有在这条线之前装卸货物的卡车,都必须完全开走之后,在这条线之后的卡车才能出发。”
- 常用选项:
DMB ISH: 屏障作用于Inner Shareable域(通常包括当前集群中的所有核心)。这是多线程编程中最常用的类型。DMB NSH: 屏障作用范围更小,性能更好,但仅用于特定场景。
- 用途: 这是最常用的屏障,用于确保多个内存操作之间的顺序。在上面的例子中,我们就需要在
data = 42和flag = 1之间插入DMB。
修正后的代码:
// CPU 0 (Producer)
data = 42;
DMB ISHST; // 确保所有Store操作完成 before后续Store操作。ISHST是ISH的一种更轻量形式,只约束写操作。
flag = 1;
2. DSB (Data Synchronization Barrier)
- 作用: 比 DMB 更严格。它确保在 DSB 指令之前的所有内存访问都完全完成(即它们的效应对整个系统都可见)之后,才会执行任何在 DSB 之后的指令(不仅仅是内存访问指令)。
- 类比: “所有在这条线之前装卸货物的卡车,都必须不但开走,而且要完全到达目的地并卸完货之后,才允许任何其他车辆(甚至是摩托车)通过这条线。”
- 用途: 常用于与系统配置相关的操作,比如在写完页表寄存器或内存映射的设备寄存器后,需要确保这些配置已完全生效,再执行后续操作。
3. ISB (Instruction Synchronization Barrier)
- 作用: 它会刷新处理器的流水线,确保所有在 ISB 之后执行的指令都是从缓存或内存中重新预取的。它主要用于自我修改代码场景,或者在更改系统控制寄存器(如 MMU、CP15 寄存器)后,确保后续指令使用新的配置上下文执行。
- 用途: 非常特殊,普通应用程序开发几乎用不到。
内存访问类型与屏障的配合
屏障指令可以更加精细地控制,通过后缀指定其作用范围:
- Full system (
SY): 影响整个系统。 - Outer Shareable (
OSH): 影响系统级一致性域。 - Inner Shareable (
ISH): 影响多核集群内部(最常见)。 - Non-shareable (
NSH): 仅影响当前核心。
以及限制操作类型:
- 存储-存储 (
ST): 只屏障写操作(如DMB ISHST)。 - 加载-加载 (
LD): 只屏障读操作。 - 加载-存储: 屏障读和写操作之间的顺序。
- 全部 (无后缀): 屏障所有内存操作。
对程序员的实际建议
- 不要手动使用屏障: 在绝大多数情况下,你不应该在应用程序代码中直接使用
DMB/DSB这样的汇编指令。 - 使用高级抽象:
- 使用锁(Mutex/Lock): 锁的获取(acquire)和释放(release)操作内部已经包含了所有必要的内存屏障。这是最简单、最安全的方式。
- 使用高级语言的原子操作: 在 C/C++ 中,使用
std::atomic<T>。当你使用load(std::memory_order_acquire)和store(std::memory_order_release)等操作时,编译器会自动为你生成正确的屏障指令。store(..., std::memory_order_release): 会确保所有之前的读写操作不会重排到它之后。它就像一个“释放栅栏”。load(..., std::memory_order_acquire): 会确保所有之后的读写操作不会重排到它之前。它就像一个“获取栅栏”。
用高级原子操作修正上面的例子:
// C++ Example
std::atomic<int> flag(0);
int data = 0;
// CPU 0
data = 42;
flag.store(1, std::memory_order_release); // 编译器会在此插入合适的屏障
// CPU 1
while (flag.load(std::memory_order_acquire) == 0) { /* spin */ }
print(data); // Guaranteed to see 42
总结
- 弱序模型: ARM 内存模型是弱排序/宽松的,允许硬件和编译器对内存操作进行重排序以提升性能。
- 核心问题: 重排序会导致多线程程序中出现违反直觉的结果。
- 解决方案: 使用内存屏障指令(DMB, DSB, ISB)来强制规定内存操作的顺序。
- 最佳实践: 不要直接使用屏障指令,而是依赖锁和高级编程语言的原子操作(如 C++
std::atomic)。这些高级工具会在底层自动、正确地使用屏障指令,确保代码的正确性和可移植性。
简单来说,ARM 将保证内存顺序的责任交给了软件。程序员必须通过同步原语告诉硬件:“代码中的这个地方,顺序至关重要,不得进行重排序”。
浙公网安备 33010602011771号