在x86架构中,如何使用MOV实现release-and-acquire(释放-获取)?

16
这个问题是对以下内容的跟进/澄清: MOV x86指令是否实现了C++11的memory_order_release原子存储? 这表明MOV汇编指令足以在x86上执行acquire-release语义。我们不需要LOCK、栅栏或xchg等。然而,我很难理解这是如何工作的。
英特尔文档Vol 3A第8章指出:

https://software.intel.com/sites/default/files/managed/7c/f1/253668-sdm-vol-3a.pdf

在单处理器(core)系统中: - 读取不与其他读取重排。 - 写入不与旧读取重排。 - 对内存的写入不与其他写入重排,但有以下例外:
但这仅适用于单核心。多核心部分似乎没有提到如何强制执行加载:
在多处理器系统中,遵循以下排序原则: - 单个处理器使用与单处理器系统相同的排序原则。 - 单个处理器的写入顺序被所有处理器以相同的顺序观察到。 - 来自单个处理器的写入不与来自其他处理器的写入有序。 - 内存排序服从因果关系(内存排序尊重传递可见性)。 - 处理器之外的任何两个存储都按一致的顺序查看。 - 锁定指令具有总顺序。
那么,MOV如何单独实现获取-释放?

MOV本身不是比使用rel-acq栅栏更加顺序一致吗?因为它只在非常有限的条件下被重新排序。这让我想起了Herb Sutter很久以前关于SC-DRF内存模型的非常有见地的演讲。 - Dean Seo
2
@DeanSeo:不,x86的硬件内存模型是SC + 带有存储转发的存储缓冲区。这就像acq_rel,而不是SC。 - Peter Cordes
@PeterCordes 很有趣!感谢您的纠正! - Dean Seo
2个回答

10
但这只适用于单核。多核部分似乎没有提到如何强制执行负载的方法: 该部分中的第一个要点非常关键:各个处理器使用与单处理器系统相同的排序原则。该声明的隐含部分是...当从高速缓存一致的共享内存中加载/存储时。也就是说,多处理器系统不会引入新的重新排序方式,它们只是意味着可能的观察者现在包括其他核心上的代码,而不仅仅是DMA/IO设备。
访问共享内存的重新排序模型是单核模型,即程序顺序+存储缓冲区=基本上是acq_rel。实际上比acq_rel稍微强一些,这没问题。 唯一的重新排序发生在每个CPU核心内部,存储一旦变为全局可见,就会同时对所有其他核心可见,并且在此之前不会对任何核心可见(除了通过存储转发进行存储的核心)。这就是为什么只有本地障碍就足以在SC +存储缓冲区模型的基础上恢复顺序一致性。(对于x86,只需要在SC存储器之后使用mfence才能使mo_seq_cst排空存储器缓冲区,然后再执行任何进一步的加载。mfencelocked指令(也是完整的障碍)不必打扰其他核心,只需让这一个等待)。

理解的一个关键点是:确实存在一个一致的共享内存视图(通过一致的高速缓存),所有处理器都共享这个视图。Intel SDM第8章的最顶部定义了一些这方面的背景知识:

这些多处理机制具有以下特点:
  • 维护系统内存一致性 - 当两个或更多处理器同时尝试访问系统内存中的同一地址时,必须提供某种通信机制或内存访问协议来促进数据一致性,并在某些情况下允许一个处理器暂时锁定内存位置。
  • 维护缓存一致性 - 当一个处理器访问另一个处理器上缓存的数据时,它不能接收到不正确的数据。如果它修改了数据,所有访问该数据的其他处理器都必须接收修改后的数据。
  • 允许对内存写入进行可预测的排序 - 在某些情况下,重要的是将内存写入以与编程时完全相同的顺序观察外部。
  • [...]

Intel 64和IA-32处理器的缓存机制和缓存一致性在第11章中讨论。

(CPU使用MESI的某个变体;实际上,Intel使用MESIF,AMD使用MOESI。)

同一章节还包括一些试金石,以帮助说明/定义内存模型。你引用的部分并不是内存模型的严格正式定义。但是第8.2.3.2节“不会对类似操作进行重排序”的部分表明,加载不会与加载重排序。另一节还表明LoadStore重排序被禁止。Acq_rel基本上阻止了所有重新排序,除了StoreLoad,这就是x86所做的。(https://preshing.com/20120913/acquire-and-release-semantics/https://preshing.com/20120930/weak-vs-strong-memory-models/
相关:

其他指令集架构

通常来说,大多数较弱的内存硬件模型也只允许本地重排序,因此屏障仍然仅在CPU核心内部是本地的,只是让该核心等待某些条件。例如,x86 mfence会阻止后续的加载和存储执行,直到存储缓冲区排空。其他指令集架构也受益于轻量级屏障以提高效率,用于处理x86在每个内存操作之间强制执行的内容,例如阻止LoadLoad和LoadStore重排序。https://preshing.com/20120930/weak-vs-strong-memory-models/

一些ISA(目前仅限于PowerPC)允许存储在对所有核心可见之前对某些其他核心可见,允许IRIW重排序。请注意,C ++中的mo_acq_rel允许IRIW重排序;只有seq_cst禁止它。大多数硬件内存模型比ISO C ++略强,并使其不可能,因此所有核心都同意存储的全局顺序。


1
哦,回答的时间很巧合。这看起来比我的好一些。 :) - GManNickG
1
@user997112:在x86上实现顺序一致性(SC或seq_cst)所需的内容中,我提到了mfence。 我提到它是为了指出mfence执行的所有操作都是本地的,在执行它的核心内部完成的。 感谢您指出我的解释可能会引起的混淆,我现在看到了,并进行了更新。 - Peter Cordes
1
@user997112:什么?不是的。acq-rel是关于其他加载/存储相对于此加载/存储的排序。例如,写入一个大缓冲区,然后data_ready.store(true, mo_release);。执行data_ready.load(mo_acquire)并看到true的读取器可以安全地读取缓冲区,即使缓冲区是非原子的。如果您只有一个64位共享变量,则不需要任何其他东西的排序,只需为该单个无锁变量使用mo_relaxed。 - Peter Cordes
2
@user997112:当您不需要太多排序时,可以通过使用比seq_cst更高效的方式来获得更好的性能。mov + mfence(或xchg)非常慢。在运行时,获取和释放是免费的,但是松散的操作可以允许编译时优化其他原子操作。 (在x86上的原子RMW操作始终是完全屏障; seq_cst纯存储是昂贵的事情。)一般来说,为了获得最大的性能,请尽可能使用弱顺序。一般来说,为了最大限度地防止设计错误,请使用默认的seq_cst,特别是如果您无法在弱ISA上实际测试代码。 - Peter Cordes
1
@user997112:哦。https://preshing.com/20120515/memory-reordering-caught-in-the-act/。当您存储然后想要加载并查看其他线程可能看到/已经看到的内容时,需要seq_cst。是的,编译时重新排序必须遵守ISO C++内存模型(对于它们不同的情况,例如,松散存储可以在编译时重新排序,或者获取加载可以相对于松散和非原子操作仅在一个方向上在编译时重新排序。即使在为x86编译的情况下,在汇编中,所有内容都是获取加载。) - Peter Cordes
显示剩余10条评论

5

更新“获取”和“释放”的语义(引用cppreference而不是标准,因为它就在手边——标准更加冗长):

memory_order_acquire: 具有此内存顺序的加载操作执行对受影响的内存位置的获取操作:当前线程中的任何读取或写入都不能在此加载之前重新排序。其他线程中释放相同原子变量的所有写入都可见于当前线程。

memory_order_release: 具有此内存顺序的存储操作执行释放操作:当前线程中的任何读取或写入都不能在此存储之后重新排序。当前线程中的所有写入对获取相同原子变量的其他线程可见。

这给了我们四个保证:

  • 获取排序:“当前线程中的任何读取或写入都不能在此加载之前重新排序”
  • 释放排序:“当前线程中的任何读取或写入都不能在此存储之后重新排序”
  • 获取-释放同步:
    • “其他线程中释放相同原子变量的所有写入都可见于当前线程。”
    • “当前线程中的所有写入对获取相同原子变量的其他线程可见。”

回顾这些保证:

  • 读取不与其他读取重新排序。
  • 写入不与较旧的读取重新排序。
  • 内存的写入不与其他写入重新排序 [..]
  • 单个处理器使用与单处理器系统相同的排序原则。

这已足以满足排序保证。

对于获取排序,请考虑已发生了原子变量的读取:对于该线程而言,显然,任何晚于此读取的后续读取或写入都将违反上述第一个或第二个项目。

对于释放排序,请考虑已发生原子变量的写入:对于该线程而言,显然,任何先前的读取或写入后移将违反上述第二或第三个项目。

唯一剩下的就是确保如果一个线程读取了一个已经释放的存储,它将看到写入线程在那时点之前所产生的所有加载。这就是需要另一个多处理器保证的地方。


  • 单个处理器的写入顺序被所有处理器以相同的顺序观察。

这已足以满足获取-释放同步。

我们已经确定当释放写入发生时,之前的所有其他写入也将发生。然后,此项目确保如果另一个线程读取了已释放的写入,它将读取写入者直到该点所产生的所有写入。 (如果没有,则会观察到单个处理器的写入顺序与单个处理器不同的顺序,从而违反该项目。)


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