x86 上的加载和存储的原子性

37

8.1.2 总线锁定

Intel 64和IA-32处理器提供了一个LOCK#信号,它在某些关键的内存操作期间自动被激活,以锁定系统总线或等效链路。当此输出信号被激活时,来自其他处理器或总线代理的对总线控制的请求将被阻塞。软件可以通过在指令前加上LOCK前缀来指定其他需要遵循LOCK语义的场合。

这是来自Intel手册第三卷。

听起来像是对内存的原子操作将直接在内存(RAM)上执行。我有点困惑,因为我在分析汇编代码输出时并没有看到“特别”的地方。

基本上,对于std::atomic<int> X; X.load()生成的汇编输出只是一个正常的mov加载指令。如果我理解得正确,X.store(2)只是mov [somewhere], 2,再加上seq_cst时的mfence, 它负责适当的内存排序,而不是原子性。这就是全部。看来它不会“跳过”cache。

我知道将对齐的整数(例如整型)移动到内存中是原子性的。然而,我感到困惑。


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

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


你的CPU(i3/i5/i7)中有任何外部总线(共享总线)吗? - osgx
自动 LOCK 信号断言的汇编中不会显示任何内容。这就是整个重点,它是自动的,不需要由代码请求...当然,如果您希望,您也可以使用 LOCK 指令。 - Brian Knoblauch
5
该手册还指出:“LOCK前缀只能添加到以下指令中,并且只能添加到目标操作数为内存操作数的指令形式:ADD,ADC,AND,BTC,BTR,BTS,CMPXCHG,CMPXCH8B,DEC,INC,NEG,NOT,OR,SBB,SUB,XOR,XADD和XCHG”,因此您不应期望看到“lock mov”。 - harold
2个回答

53
听起来内存的原子操作将直接在内存(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_acquiremo_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

这意味着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的优化手册称,当缓存锁不足时,会发生这种情况。)

@Gilgamesz:这个回答是否比你需要的更长/更详细,或者它仍然有一些遗漏的地方?我有一些重新表述相同含义但更清晰简洁的想法,例如,“原子性只是意味着没有任何东西可以将其观察为多个步骤。物理上/电上同时发生并不是必要的,但是这是实现这一点的一种便捷方式。” - Peter Cordes
Peter Cordes,这就足够了。现在一切都清楚了 :)。 - Gilgamesz

3

LOCK#信号(CPU封装/插座的引脚)在旧芯片上被用于带有LOCK前缀的原子操作,现在有了缓存锁。对于更复杂的原子操作,比如.exchange.fetch_add,你需要使用LOCK前缀或其他类型的原子指令(cmpxchg/8/16?)。

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

在Pentium 4、Intel Xeon和P6系列处理器中,锁定操作是通过缓存锁或总线锁来处理的。如果内存访问可缓存且仅影响单个缓存行,则会调用缓存锁,并且在操作期间不会锁定系统总线和实际的系统内存位置。

您可以查看Paul E. McKenney的论文和书籍: * 现代微处理器中的内存排序,2007年 * 内存屏障:软件黑客的硬件视角,2010年 * perfbook,"并行编程难吗?如果是,你能做些什么?" 同时, * Intel 64架构内存排序白皮书,2007年。
x86/x86_64需要内存屏障以防止加载重排。第一篇论文中提到了这点。
x86(.. AMD64与x86兼容..)由于x86 CPU提供“进程排序”,使得所有CPU都同意给定CPU对内存写入的顺序,因此对于CPU而言,smp_wmb()原语是无操作的[7]。然而,需要编译器指令来防止编译器执行会导致在smp_wmb()原语跨越重排序的优化。
另一方面,x86 CPU传统上没有为负载提供排序保证,因此smp_mb()smp_rmb()原语扩展为lock;addl。这个原子指令作为负载和存储的障碍。
读内存屏障(来自第二篇论文)的作用是:读内存屏障仅对执行它的CPU上的负载进行排序,以便在读内存屏障之前的所有负载似乎已经完成,在读内存屏障之后的任何负载之前。例如,“Intel 64 Architecture Memory Ordering White Paper”。
英文原文已经提供了详细的解释,这段文字主要是关于Intel 64内存访问指令的顺序保证和mfence指令的定义。其中,Intel 64内存访问指令保证读写4字节对齐的双字内存操作看起来像是单个内存访问。此外,它还遵循一些规则,如加载指令不会与其他加载指令重新排序,在多处理器系统中,内存访问顺序遵循因果关系等。而mfence指令则是用于对所有之前发出的从内存读取和存储到内存的指令执行序列化操作,以确保在mfence指令之前发出的所有加载和存储指令在任何跟随mfence指令的加载或存储指令之前变得全局可见。

gcc/clang实际上不会为seq_cst加载发出任何屏障指令。我猜这种语义允许较早的松散存储在seq_cst加载后变得全局可见? - Peter Cordes
@PeterCordes,你写的似乎是正确的。在C++参考文献中写道:“原子操作标记的memory_order_seq_cst不仅可以像release/acquire ordering一样有序地处理内存(在一个线程中发生的所有事情都会成为在执行load操作的线程中可见的副作用)”。因此,C++定义的顺序一致性只确保了release/acquire语义。但是,正如你所知道的那样,这种语义允许StoreLoad重排序,因此它在load操作之前不会发出内存屏障。实际上,早期的relaxed stores可以在load操作之前全局可见。 - Gilgamesz
但是只有早期的存储器,因为在x86上加载操作类似于围栏。另外,请注意,C ++定义的顺序一致性比一般观点所理解的语义要弱。在preshing.com上,关于该语义如下所述: “在一个顺序一致的内存模型中,没有内存重排序。”http://preshing.com/20120930/weak-vs-strong-memory-models/ 我是对的吗? - Gilgamesz
@Gilgamesz:Preshing没有声称C++的memory_order_seq_cst比通常意义下的顺序一致性更弱。事实上,他明确表示相反的意见。在C++参考文献中,你截取了句子的其余部分,在它涉及到seq_cst和acq_rel之间的区别之前。 - Peter Cordes
@PeterCordes,我不懂:Preshing说:“在C ++11中,当对原子库类型执行操作时,您可以使用默认排序约束memory_order_seq_cst。如果您执行这些操作,则工具链将限制编译器重排并发射CPU特定指令,这些指令作为适当的内存屏障类型。”一切都很好,但是,为什么编译器在您第一个评论所述的情况下没有发出 mfence - Gilgamesz
@Gilgamesz:我并不是要解释那个,只是纠正你错误的陈述。 我想我找到了答案seq_cst 的含义:acq-load / rel-store(纯加载不能是释放),加上存在一个单一的总顺序,在此顺序中,所有线程观察到所有修改以相同的顺序发生。 显然您可以在负载之前不使用栅栏来获得该效果,并且这与不重新排序的情况不同;仅仅是没有影响的重新排序。请参见C++规则 The Synchronizes-With Relation - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接