volatile关键字的作用和原理

53 阅读10分钟

原子指令、内存屏障和内存顺序的结合

多核缓存

在只有单核的时候,数据竞争并不存在。因为从单核的角度来看,只有一份数据——它的 L1 缓存。当有多个核心,每个都有自己的 L1 缓存和 L2 缓存时,保持多个缓存中的数据同步就是一个分布式系统问题。就像在没有分片和复制的情况下,只有一个 MySQL 实例拥有所有数据,更容易进行推理一样(这也是 Spanner 受欢迎的原因)。

保持多个 CPU 缓存同步的问题称为缓存一致性。

image-20231017171519565.png

写传播

任何缓存中对数据的修改必须传播到其他副本(缓存行)中。来自不同核心的写操作必须对其他核心可见。

事务串行化

对单个内存位置的读/写操作必须按照相同的顺序被所有处理器看到。

如果核心 P 写入 A 到内存位置 L 然后写入 B,那么没有人可以在读到 B 之前读到 A。

注意,缓存一致性是针对单个内存位置的属性。它没有规定如果核心 P1 写入内存位置 L1 而 P2 写入内存位置 L2 的情况。

如果对所有内存位置的访问都是可串行化的,那么称为顺序一致性。在分布式系统术语中,它与可串行化相同。

MESI 是一个常见的缓存一致性协议。它支持回写缓存,因此具有很好的性能。MESI 中的缓存行只能处于四种可能的状态之一:

  • Modified:这是当数据被修改但还没有写回主内存时的状态。不允许从主内存读取数据。数据不能存在于其他 CPU 的缓存行中。
  • Exclusive:缓存行只存在于当前缓存中。数据与主内存匹配。
  • Shared:缓存行可以在其他缓存中有多个副本。数据与主内存匹配。
  • Invalid:缓存行无效或空闲。

这看起来可能很复杂,尤其是如果你试图跟踪所有可能的状态转换。但其实并不难。由于总线嗅探,任何缓存状态变化都会原子地广播到所有其他缓存。它本质上实现了原子广播,在分布式系统中由于可能发生部分故障而不是一个简单的问题。但是当涉及到单个机器中的多个缓存时,我们不必担心部分故障。

如果你仔细阅读 MESI 模型,它确实提供了顺序一致性(如果它一直在做原子广播也不奇怪)。然而,由于存在 Store Buffer 和 Invalidation Queue,它被破坏了。

当 P 尝试写入它的缓存并且它的缓存状态是“无效”的时候,它需要先从主内存(或从其他缓存转移)获取缓存行,然后才能设置缓存行的独占所有权并将其状态设置为“修改”。这可能非常慢,因此引入了 Store Buffer。

在这种情况下,在向所有缓存发出读-无效化之后,写操作被缓存在缓存本地的 store buffer 中。在从主内存获取之后,缓存行最终会用写入的值更新。在这个窗口期内,从缓存读取的操作必须搜索它的 store buffer 以获取缓冲的写入。它对本地缓存有效,但对其他缓存无效。

注意,虽然一个 CPU 可以从它的 store buffer 中读取它自己之前的写入,但其他 CPU 在它们从 store buffer 刷新到缓存之前无法看到这些写入 - 一个 CPU 无法扫描其他 CPU 的 store buffer。( en.wikipedia.org/wiki/MESI_p…

这里引用了一下Linus的说法

HOWEVER. If you do the sub-word write using a regular store, you are now invoking the one non-coherent part of the x86 memory pipeline: the store buffer. Normal stores can (and will) be forwarded to subsequent loads from the store buffer, and they are not strongly ordered wrt cache coherency while they are buffered. IOW, on x86, loads are ordered wrt loads, and stores are ordered wrt other stores, but loads are not ordered wrt other stores in the absence of a serializing instruction, and it's exactly because of the write buffer.

然而。如果你使用常规存储来做子字节写入,你现在就调用了 x86 内存管道中唯一不一致的部分:store buffer。正常的存储可以(并且会)从 store buffer 中转发给后续的加载,并且在它们被缓冲时,它们与缓存一致性没有强制顺序。换句话说,在 x86 上,加载相对于加载是有序的,存储相对于其他存储是有序的,但加载相对于其他存储在没有串行化指令的情况下是无序的,这正是由于写缓冲区。

失效队列(INVALID QUEUE)

当一个cpu收到来自另一个cpu的无效化消息时,它会确认并异步地应用无效化。与 store buffer 不同,本地核心不能为读操作扫描无效化队列。

这就是为什么我们需要内存屏障。一个 store memory barrier 将 store buffer 刷新到主内存,使其对全局可见。这也可以实现为阻塞全局加载直到写入刷新到主内存,这具有相同的效果。

一个 load barrier 将刷新无效化队列,因此来自其他 CPU 的写入对本地缓存可见。现在你明白了为什么从一个核心传递数据到另一个核心需要一个带有 store barrier 的 store 和一个带有 load barrier 的 load。

我们不应该担心上下文切换,因为在上下文切换时清空 store buffer 和使 CPU 缓存无效似乎是合理的。

重排序(REORDERING)

如果 Store Buffer 和 Invalidation Queue 还不够复杂,还有编译器重排序和 CPU 重排序。Jeff Preshing 有很多关于内存模型的好文章,这篇文章讲述了如何捕捉 CPU 重排序。

CPU 中有一个叫做 Load Queue 的结构,它可以推测性地(乱序地)执行加载操作。

当你想阻止编译器重新排序读和写操作时,你经常看到 asm volatile("" ::: "memory");这样的代码。

为了处理 CPU 重排序和顺序一致性的缺失(由于 store buffer 和 invalidation queue),我们经常使用 CPU 屏障。在 x86 上,我们有三种屏障 mfence、sfence 和 lfence。CPU 屏障是隐式的编译器屏障。

Jeff Preshing 有一篇关于内存屏障的好文章。通常有四种类型的屏障 LoadLoadLoadStoreStoreLoadStoreStore

LoadLoad用于防止读操作之间的重排序。这在当一个线程根据另一个线程写入的标志来消费一些数据时很有用,所以读取数据总是在检查标志之后发生。

类似地StoreStore屏障用于防止写操作之间的重排序(sfence on x86)。这在当一个线程使用一个标志来发布一些数据时很有用,所以写入数据总是在设置标志之前发生。

LoadStore屏障禁止将写操作之间的重排序(sfence on x86)。这在当一个线程使用一个标志来发布一些数据时很有用,所以写入数据总是在设置标志之前发生。

StoreLoad屏障是最强的屏障,它禁止所有的重排序(mfence on x86)。这在当一个线程发布一些数据后设置一个标志时很有用,所以读取数据总是在设置标志之前发生。

注意,x86的load和store操作都是原子的。这意味着如果你有一个64位的整数,你可以安全地从一个线程读取它,而另一个线程正在写入它,而不会看到部分写入的值。然而,如果你有一个128位的整数(或者更大),你就需要使用cmpxchg16b等指令来实现原子性。

执行对所有在 MFENCE 指令之前发出的从内存加载和存储到内存指令的串行化操作。这个串行化操作保证了在 MFENCE 指令之前按程序顺序发出的每个加载和存储指令在任何在 MFENCE 指令之后发出的加载或存储指令变得全局可见之前变得全局可见。MFENCE 指令与所有加载和存储指令、其他 MFENCE 指令、任何 LFENCE 和 SFENCE 指令以及任何串行化指令(如 CPUID 指令)有序。

实际上,它会在任何加载(全局)可以继续之前清空存储缓冲区。

相对于 SFENCE 指令之前的所有内存存储进行处理器执行排序。处理器确保 SFENCE 之前的每个存储在 SFENCE 之后的任何存储变得全局可见之前变得全局可见。SFENCE 指令与内存存储、其他 SFENCE 指令、MFENCE 指令和任何串行化指令(如 CPUID 指令)有序。它与内存加载或 LFENCE 指令无序。

它不必清空存储缓冲区。它通常用于非时序指令,当默认情况下,操作绕过缓存并显式地“破坏”缓存一致性时。在 NT 存储后插入 sfence,可以确保数据对其他核心可见。

对所有在 LFENCE 指令之前发出的从内存加载指令执行串行化操作。具体来说,LFENCE 不会执行,直到所有先前的指令在本地完成,并且没有后续指令开始执行,直到 LFENCE 完成。特别地,一个从内存加载并且在 LFENCE 之前发出的指令会在 LFENCE 完成之前从内存接收数据。

实际上,它会清空本地失效队列和重排序缓冲区(加载队列)。我不知道这在 x86 上有什么用处。

x86 被认为具有强内存模型。我能找到的最好的定义是

强硬件内存模型是一种每个机器指令都隐含地具有获取和释放语义的模型。因此,当一个 CPU 核心执行一系列写操作时,每个其他 CPU 核心都以相同的顺序看到这些值改变。

[在 x86 上,除了非时序指令外,所有读操作都具有获取语义,所有写操作都具有释放语义。这意味着你可以免费获得 lfence 和 sfence。但是 lfense + sfence != mfence 或完全内存屏障。具体来说,对于 StoreLoad,你可以将获取-加载重排序到释放-存储之前。事实上,这在 x86 上确实发生了。

读-修改-写操作与内存屏障也有关系。例如

lock 是一个信号前缀,它确保读-修改-写(xadd 在这种情况下)被原子地执行。它也像 x86 上的 xchg 一样充当一个完全的内存屏障。在其他平台上,成功的 RMW 操作可能具有不同的内存顺序,这在 C++ 标准原子 API 中是允许的。

[现在让我们切换到 C++ 并讨论高级内存顺序。获取-读操作禁止在它之前(按程序顺序)重排序读或写操作。释放-写操作禁止在它之后(按程序顺序)重排序读或写操作。它没有说任何关于屏障的事情,这只是实现细节。实际上它看起来像( preshing.com/20120913/ac… ):

image-20231017171540211.png

在 x86 上,这是默认行为,不需要额外的内存屏障。对于弱内存模型,重排序可能会发生

它发出原子指令,例如 x86 上的 lock cmpxchg,以对 std::atomic futex 执行读-修改-写操作。

如果 RMW 操作成功,则需要获取语义来进行 RMW 操作。这是必需的,以便在临界区内的指令不会被重排序到 RMW 操作之上。释放锁时也是如此,你执行一个释放-存储操作,以防止在释放操作之后重排序指令。

[这里有一个真实的例子。在 Folly MicroSpinLock ( github.com/facebook/fo… )中

  bool cas(uint8_t compare, uint8_t newVal) noexcept {
    return std::atomic_compare_exchange_strong_explicit(
        payload(),
        &compare,
        newVal,
        std::memory_order_acquire,
        std::memory_order_relaxed);
  }