x86 上的原子性

Atomicity on x86

8.1.2 Bus Locking

Intel 64 and IA-32 processors provide a LOCK# signal that is asserted automatically during certain critical memory operations to lock the system bus or equivalent link. While this output signal is asserted, requests from other processors or bus agents for control of the bus are blocked. Software can specify other occasions when the LOCK semantics are to be followed by prepending the LOCK prefix to an instruction.

来自英特尔手册,第 3 卷

听起来像对内存的原子操作将直接在内存(RAM)上执行。我很困惑,因为我在分析程序集输出时看到 "nothing special"。基本上,为 std::atomic<int> X; X.load() 生成的汇编输出只放置 "extra" mfence。但是,它负责正确的内存排序,而不是原子性。如果我理解正确的话 X.store(2) 就是 mov [somewhere], 。就这样。它似乎没有 "skip" 缓存。我知道将对齐(例如整数)移动到内存是原子的。但是,我很困惑。


所以,我提出了我的疑问,但主要问题是:

CPU内部是如何实现原子操作的?

LOCK#信号(cpu package/socket的引脚)在旧芯片上使用(用于LOCK前缀原子操作),现在有缓存锁。对于更复杂的原子操作,如 .exchange.fetch_add,您将使用 LOCK prefix 或其他类型的原子指令(cmpxchg/8/16?)。

相同的手册,系统编程指南部分:

In the Pentium 4, Intel Xeon, and P6 family processors, the locking operation is handled with either a cache lock or bus lock. If a memory access is cacheable and affects only a single cache line, a cache lock is invoked and the system bus and the actual memory location in system memory are not locked during the operation

您可以查看 Paul E. McKenney 的论文和书籍: * Memory Ordering in Modern Microprocessors, 2007 * Memory Barriers: a Hardware View for Software Hackers, 2010 * perfbook, "Is Parallel Programming Hard, And If So, What Can You Do About It?"

并且 * Intel 64 Architecture Memory Ordering White Paper, 2007.

x86/x86_64 需要内存屏障以防止加载重新排序。来自第一篇论文:

x86 (..AMD64 is compatible with x86..) Since the x86 CPUs provide “process ordering” so that all CPUs agree on the order of a given CPU’s writes to memory, the smp_wmb() primitive is a no-op for the CPU [7]. However, a compiler directive is required to prevent the compiler from performing optimizations that would result in reordering across the smp_wmb() primitive.

On the other hand, x86 CPUs have traditionally given no ordering guarantees for loads, so the smp_mb() and smp_rmb() primitives expand to lock;addl. This atomic instruction acts as a barrier to both loads and stores.

什么是读取内存屏障(来自第二篇论文):

The effect of this is that a read memory barrier orders only loads on the CPU that executes it, so that all loads preceding the read memory barrier will appear to have completed before any load following the read memory barrier.

例如,来自"Intel 64 Architecture Memory Ordering White Paper"

Intel 64 memory ordering guarantees that for each of the following memory-access instructions, the constituent memory operation appears to execute as a single memory access regardless of memory type: ... Instructions that read or write a doubleword (4 bytes) whose address is aligned on a 4 byte boundary.

Intel 64 memory ordering obeys the following principles: 1. Loads are not reordered with other loads. ... 5. In a multiprocessor system, memory ordering obeys causality (memory ordering respects transitive visibility). ... Intel 64 memory ordering ensures that loads are seen in program order

此外,mfence 的定义:http://www.felixcloutier.com/x86/MFENCE.html

Performs a serializing operation on all load-from-memory and store-to-memory instructions that were issued prior the MFENCE instruction. This serializing operation guarantees that every load and store instruction that precedes the MFENCE instruction in program order becomes globally visible before any load or store instruction that follows the MFENCE instruction.

It sounds like the atomic operations on memory will be executed directly on memory (RAM).

不,只要系统中每个可能的观察者都将操作视为原子操作,操作就可以只涉及缓存。

满足这个要求要困难得多(比如lock add [mem], eax,尤其是对于未对齐的地址),这是CPU 可能断言LOCK# 信号的时候。您仍然不会在 asm 中看到更多内容:硬件为 locked 指令实现 ISA 所需的语义。

尽管我怀疑现代 CPU 上是否有物理外部 LOCK# 引脚,其中内存控制器内置于 CPU,而不是单独的 northbridge chip ].


std::atomic<int> X; X.load() puts only "extra" mfence.

编译器不会对 seq_cst 负载进行 MFENCE。

我想我曾读到过旧的 MSVC 确实为此发出了 MFENCE(也许是为了防止使用未受保护的 NT 商店重新排序?或者而不是在商店上?)。但它不再存在了:我测试了 MSVC 19.00.23026.0。在 this program that dumps its own asm in an online compile&run site.

的 asm 输出中查找 foo 和 bar

我们在这里不需要栅栏的原因是 x86 内存模型 disallows both LoadStore and LoadLoad 重新排序。较早的(非 seq_cst)存储仍然可以延迟到 seq_cst 加载之后,因此它不同于在 X.load(mo_acquire);

之前使用独立 std::atomic_thread_fence(mo_seq_cst);

If I understand properly the X.store(2) is just mov [somewhere], 2

这与您认为需要加载的想法一致mfence; seq_cst 加载或存储中的一个或另一个需要一个完整的屏障来防止 StoreLoad reordering which could otherwise happen.

在实践中,编译器开发者选择了便宜的加载(mov)/昂贵的存储(mov+mfence),因为加载更常见。 C++11 mappings to processors.

(x86 内存排序模型是程序顺序加上存储转发的存储缓冲区()。这使得 mo_acquiremo_release 在 asm 中免费,只需要阻止编译时重新排序,并让我们选择是否将 MFENCE 完全屏障放在加载或存储上。)

所以 seq_cst 商店是 mov+mfence xchg 讨论了 xchg 在某些 CPU 上的性能优势。在 AMD 上,MFENCE 被 (IIRC) 记录为具有额外的流水线序列化语义(用于指令执行,而不仅仅是内存排序),可以阻止乱序执行,而在一些英特尔 CPUs 中实际上( Skylake)也是如此。

MSVC 的存储 asm 与 clang's 相同,使用 xchg 使用相同的指令执行存储 + 内存屏障。

原子发布或宽松存储可以只是 mov,它们之间的区别只是允许多少编译时重新排序。


这个问题看起来像你之前 的第 2 部分,你问的是:

How does the CPU implement atomic operations internally?

正如您在问题中指出的那样,原子性与任何其他操作的顺序无关。 (即 memory_order_relaxed)。这只是意味着该操作作为一个不可分割的操作发生,hence the name,而不是作为多个部分发生,部分发生在其他事情之前,部分发生在其他事情之后。

您获得了原子性 "for free",无需额外的硬件即可对齐加载或存储内核、内存和 I/O 总线(如 PCIe)之间的数据路径大小。 即在各级缓存之间,以及不同内核的缓存之间。内存控制器是现代设计中 CPU 的一部分,因此即使是访问内存的 PCIe 设备也必须通过 CPU 的系统代理。 (这甚至让 Skylake 的 eDRAM L4(在任何桌面 CPUs :( ) 中都不可用)用作内存端缓存(不像 Broadwell,它将它用作 L3 IIRC 的受害者缓存),位于内存和一切之间else 在系统中,因此它甚至可以缓存 DMA)。

这意味着 CPU 硬件可以做任何必要的事情来确保存储或加载相对于系统中可以观察到它的 任何东西 是原子的.如果有的话,这可能并不多。 DDR 内存使用足够宽的数据总线,64 位对齐存储确实在同一个周期内通过内存总线电气传输到 DRAM。 (有趣的事实,但并不重要。像 PCIe 这样的串行总线协议不会阻止它成为原子,只要单个消息足够大。而且由于内存控制器是唯一可以直接与 DRAM 对话的东西,它在内部做什么并不重要,重要的是它与 CPU) 其余部分之间的传输大小。但无论如何,这是 "for free" 部分:不需要临时阻止其他请求来保持原子传输的原子性。

,但不是更广泛的访问。低功耗实现可以自由地将矢量 loads/stores 分解为 64 位块,就像 P6 从 PIII 到 Pentium M 所做的那样。


原子操作发生在缓存中

请记住,原子只是意味着所有观察者都将其视为已发生或未发生,从未部分发生。没有要求它实际上立即到达主内存(或者根本没有,如果很快被覆盖)。 原子地修改或读取 L1 缓存足以确保任何其他核心或 DMA 访问将看到对齐的存储或加载作为单个原子操作发生。如果此修改发生在store 执行(例如,由于乱序执行而延迟,直到 store 退出)。

现代 CPU 像 Core2 这样到处都是 128 位路径的系统通常具有原子 SSE 128b loads/stores,超出了 x86 ISA 的保证。但请注意有趣的异常 on a multi-socket Opteron probably due to hypertransport. 这证明原子地修改 L1 缓存不足以为比最窄数据路径更宽的存储提供原子性(在这种情况下,这不是 L1 缓存和执行单元之间的路径) .

对齐很重要:跨越高速缓存行边界的加载或存储必须在两个单独的访问中完成。这使它成为非原子的。

AMD/Intel。 (或者仅适用于 P6 及更高版本的 Intel,不要跨越高速缓存行边界)。这意味着整个缓存行(现代 CPUs 上的 64B)在 Intel 上以原子方式传输,即使它比数据路径更宽(Haswell/Skylake 上 L2 和 L3 之间的 32B)。这种原子性在硬件中并不完全 "free",并且可能需要一些额外的逻辑来防止负载读取仅部分传输的缓存行。尽管缓存行传输仅在旧版本失效后发生,因此在传输发生时核心不应该从旧副本读取。 AMD 在实践中可以在更小的边界上撕裂,可能是因为使用了不同的 MESI 扩展,可以在缓存之间传输脏数据。

对于更广泛的操作数,例如以原子方式将新数据写入结构的多个条目,您需要使用锁来保护它,所有对其的访问都尊重它。 (您可以使用带有重试循环的 x86 lock cmpxchg16b 来执行原子 16b 存储。请注意 。)


原子读取-修改-写入变得更难

相关:我在 上的回答对此进行了更详细的介绍。

每个内核都有一个与所有其他内核一致的私有 L1 缓存(使用 MOESI 协议)。缓存行以大小从 64 位到 256 位不等的块在缓存级别和主内存之间传输。 (这些传输实际上可能在整个缓存行粒度上是原子的?)

要执行原子 RMW,核心可以将 L1 缓存行保持在已修改状态,而不接受对加载和存储之间受影响的缓存行的任何外部修改,系统的其余部分将把操作视为原子。 (因此它 原子的,因为通常的无序执行规则要求本地线程将其自己的代码视为在程序顺序中具有 运行。)

它可以通过在原子 RMW 运行时不处理任何缓存一致性消息来做到这一点(或一些更复杂的版本,它允许其他操作的更多并行性)。

未对齐的 locked 操作是一个问题:我们需要其他核心来查看对两个缓存行的修改作为单个原子操作发生。 可能需要实际存储到 DRAM,并进行总线锁定。 (AMD 的优化手册说,当缓存锁不足时,它们的 CPU 就会发生这种情况。)