听起来内存的原子操作将直接在内存(RAM)上执行。
不,只要系统中的每个可能的观察者都将操作视为原子操作,则操作可以仅涉及缓存。
满足此要求对于原子读取修改写入操作(例如lock add [mem],eax,尤其是使用不对齐地址时)要困难得多,这时CPU可能会断言LOCK#信号。您在汇编语言中仍不会看到更多内容:硬件实现了ISA所需的lock指令语义。
虽然我怀疑现代CPU上有一个物理的外部LOCK#引脚,在其中内存控制器内置于CPU而不是单独的northbridge芯片中。
std::atomic<int> X; X.load()
只会放置“额外”的 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 的汇编输出中的 foo 和 bar。
我们不需要在这里设置围栏的原因是x86内存模型
禁止同时
LoadStore和LoadLoad重排序。早期(非seq_cst)的存储仍然可以延迟到seq_cst加载之后,因此它与在
X.load(mo_acquire);
之前使用独立的
std::atomic_thread_fence(mo_seq_cst);
不同。
如果我理解正确,X.store(2)
只是mov [somewhere], 2
这与您认为加载需要
mfence
的想法一致; seq_cst加载或存储中的一个需要完整的屏障以防止
否则可能发生的StoreLoad重排序。
在实践中,编译器开发者选择了
廉价的加载(mov)/昂贵的存储(mov+mfence),因为加载更常见。参见
C++11 mappings to processors。
(x86内存排序模型是程序顺序加上带有存储转发(
也请参阅)的存储缓冲区。这使得
mo_acquire
和
mo_release
在汇编中免费,只需要阻止编译时重新排序,并让我们选择在加载或存储上放置MFENCE完整屏障。)
所以,seq_cst存储要么是mov+mfence,要么是xchg。
为什么std :: atomic store使用XCHG具有顺序一致性?讨论了某些CPU上xchg的性能优势。在AMD上,MFENCE(如果我没记错的话)被记录为具有额外的序列化管道语义(用于指令执行,而不仅仅是内存排序),它阻止了乱序执行,在某些实际情况下(Skylake)也是如此。
MSVC 的存储汇编与 clang 相同,使用 xchg
执行存储和内存屏障。
原子释放或松散存储可以只使用 mov
,它们之间的区别仅在于允许多少编译时重排序。
这个问题看起来像是你之前
《C++中的内存模型:顺序一致性和原子性》的第二部分,你问道:
CPU如何在内部实现原子操作?
正如你在问题中指出的那样,原子性与任何其他操作的排序无关(即memory_order_relaxed
)。这只意味着该操作作为单个不可分割的操作(因此得名),而不是多个部分,可以在其他操作的某些部分之前和某些部分之后部分发生。
你可以免费获得原子性,对于大小不超过核心、内存和I/O总线(如PCIe)之间数据路径的对齐加载或存储操作,无需额外硬件。即在各级缓存之间,以及不同核心的缓存之间。现代设计中,内存控制器是CPU的一部分,因此即使是PCIe设备访问内存也必须通过CPU的系统代理。这甚至让Skylake的eDRAM L4(不可在任何桌面CPU上使用:()作为内存侧缓存工作(与Broadwell不同,后者将其用作L3 IIRC的受害者缓存),坐落在内存和系统中的其他所有东西之间,因此它甚至可以缓存DMA。
![Skylake system agent diagram, from IDF via ARStechnica](https://istack.dev59.com/Z4P3T.webp)
这意味着CPU硬件可以做任何必要的事情,以确保存储或加载在系统中与其他任何观察它的东西都是原子的。这可能不多,如果有的话。DDR内存使用足够宽的数据总线,使得64位对齐的存储确实会在同一周期内通过内存总线电气化到DRAM中(有趣的事实,但并不重要)。像PCIe这样的串行总线协议不会阻止它成为原子操作,只要单个消息足够大即可。由于内存控制器是唯一可以直接与DRAM通信的设备,所以它内部所做的操作并不重要,只有它与CPU其余部分之间的传输大小才有影响。但无论如何,这就是“免费”的部分:不需要临时阻塞其他请求来保持原子传输的原子性。
x86保证对齐的64位加载和存储是原子的,但更宽的访问则不是。低功耗实现可以将向量加载/存储分成64位块,例如从P6到Pentium M的PIII所做的那样。
原子操作发生在缓存中
请记住,原子操作只意味着所有观察者都会看到它已经发生或尚未发生,永远不会部分发生。并没有要求它立即到达主内存(如果很快被覆盖,则根本不需要)。原子地修改或读取L1缓存足以确保任何其他核心或DMA访问将看到对齐的存储器或加载器作为单个原子操作发生。如果这种修改在存储器执行后很长时间才发生(例如由于乱序执行而延迟到存储器退役),那也没关系。
现代CPU(例如具有128位路径的Core2)通常具有原子SSE 128b加载/存储,超出了x86 ISA所保证的范围。但请注意有趣的例外在多插槽Opteron上,可能是由于超级传输。 这证明,仅原子地修改L1缓存不足以为比最窄数据路径更宽的存储提供原子性(在这种情况下,不是L1缓存和执行单元之间的路径)。
对齐很重要:跨越缓存行边界的负载或存储必须进行两次分开访问,这使它非原子性。
{{link1:x86保证了在AMD / Intel上不跨越8B边界的缓存访问是原子的,最多可以访问8个字节。 (或仅适用于Intel P6及更高版本,不跨越缓存行边界)。这意味着在Intel上以原子方式传输整个缓存行(现代CPU上为64B),尽管这比数据路径(Haswell / Skylake上L2和L3之间的32B)更宽。这种原子性在硬件上并非完全“免费”,可能需要一些额外的逻辑来防止一个加载从读取部分传输的缓存行。虽然在旧版本被无效化之后才会发生缓存行转移,因此核心不应在有传输正在进行时从旧副本中读取。 AMD在实践中可以在较小的边界上撕裂,可能是因为使用了与MESI不同的扩展,可以在缓存之间传输脏数据。
对于更广泛的操作数,比如原子地将新数据写入结构体的多个条目中,您需要使用锁来保护它,以便所有访问都尊重该锁。(您可以使用带有重试循环的x86
lock cmpxchg16b
来执行原子 16b 存储。请注意,
没有办法在没有互斥锁的情况下模拟它。)
原子读取-修改-写入操作是更加困难的
相关:我的回答 Can num++ be atomic for 'int num'? 更详细地讲解了这个问题。
每个核心都有一个私有的 L1 缓存,该缓存与所有其他核心协同(使用 MOESI 协议)。缓存行在各级缓存和主内存之间以从 64 位到 256 位不等的块大小传输。 (这些传输实际上可以在整个缓存行粒度上是原子性的?)
要执行原子 RMW 操作,核心可以将 L1 缓存中的一行保持为已修改状态,在加载和存储之间不接受对受影响的缓存行的任何外部修改,其余系统将看到操作为原子操作。(因此,它是原子的,因为通常的乱序执行规则要求本地线程将自己的代码视为按程序顺序运行。)
它可以通过在原子 RMW 进行时不处理任何缓存一致性消息(或者更复杂的版本,允许其他操作具有更多的并行性)来实现此目的。
未对齐的“lock”操作是一个问题:我们需要其他核心将两个缓存行的修改视为单个原子操作。这可能需要实际存储到DRAM,并采取总线锁定。(AMD的优化手册称,当缓存锁不足时,会发生这种情况。)