比较并交换 C++0x

17

来自于关于C++原子类型和操作的C++0x提案:

29.1 顺序和一致性 [atomics.order]

添加一个新的子条款,包含以下段落。

枚举类型 memory_order 指定了详细的常规(非原子)内存同步顺序,如 [由N2334添加的新章节或其采纳后的后继] 中所定义,并可能提供操作排序。它的枚举值及其含义如下。

  • memory_order_relaxed

该操作不会对内存进行排序。

  • memory_order_release

在受影响的内存位置上执行释放操作,从而通过应用于原子变量使常规内存写入对其他线程可见。

  • memory_order_acquire

在受影响的内存位置上执行获取操作,从而使其他线程中的常规内存写入通过应用于原子变量被释放,并对当前线程可见。

  • memory_order_acq_rel

该操作具有获取和释放语义。

  • memory_order_seq_cst

该操作具有获取和释放语义,并且另外具有顺序一致的操作排序。

在提案的后面:

bool A::compare_swap( C& expected, C desired,
        memory_order success, memory_order failure ) volatile

where one can specify memory order for the CAS.


我的理解是,“memory_order_acq_rel”只会同步那些操作所需的内存位置,而其他内存位置可能保持不同步(它不会表现为内存屏障)。
现在,我的问题是 - 如果我选择“memory_order_acq_rel”,并将其应用于整数类型,例如整数,这在现代消费者处理器(如多核Intel i7)上通常如何转换为机器代码?其他常用架构(x64、SPARC、ppc、arm)呢?
特别是(假设具体编译器,比如gcc):
  1. 如何使用上述操作比较和交换整数位置?
  2. 这样的代码将产生什么指令序列?
  3. 在i7上,该操作是否无锁?
  4. 这种操作是否会在i7上运行完整的缓存一致性协议,像内存屏障一样同步不同处理器核的缓存?还是只会同步此操作所需的内存位置?
  5. 与前面的问题相关 - 在i7上使用acq_rel语义是否有任何性能优势?其他架构呢?
感谢所有答案。

“来自C++0x关于C++原子类型和操作的提案:”你引用的文本是一个非常非常糟糕的解释。 - curiousguy
2个回答

8
这里的答案并不简单。究竟会发生什么以及意味着什么,取决于很多因素。对于缓存一致性/内存的基本理解,也许我的最近的博客文章会有所帮助:

但是除此之外,让我试着回答几个问题。首先,下面的函数对支持什么非常有希望:非常细粒度地控制您获得的内存顺序保证的强度。这在编译时重排中是合理的,但通常不适用于运行时屏障。

compare_swap( C& expected, C desired,
        memory_order success, memory_order failure )

架构并不总能完全按照您的要求实现此功能;许多架构需要将其加强到足以实现的程度。当您指定memory_order时,您正在指定重新排序的方式。使用英特尔的术语,您将指定所需的栅栏类型,其中有三种:完整栅栏、加载栅栏和存储栅栏。(但在x86上,仅在使用类似NT存储的弱序指令时,加载栅栏和存储栅栏才有用;原子操作不使用它们。常规的加载/存储会给您除了存储可以出现在后面的加载之后的所有内容)。仅仅因为您想在该操作上使用特定的栅栏并不意味着它被支持,在这种情况下,我希望它总是会退回到完整的栅栏。(请参见Preshing's article关于内存屏障的文章)
一位编程人员可能会使用LOCK CMPXCHG指令来实现CAS,这适用于x86(包括x64),无论内存排序如何。这意味着使用了一个完整的屏障;x86没有一种方法使读取修改写操作原子化而不使用lock前缀,这也是一个完整的屏障。纯存储和纯加载可以自己成为原子操作,在许多ISA中,需要屏障来处理超过mo_relaxed的任何事情,但x86在汇编中可以免费使用acq_rel

这个指令是无锁的,尽管所有试图对同一位置进行CAS操作的核心都会争夺对其的访问权,因此你可以说它并不是真正的无等待(wait-free)。(使用它的算法可能不是无锁的,但操作本身是无等待的,请参见维基百科上的非阻塞算法文章)。在非x86架构中,使用LL/SC而不是lock指令的情况下,C++11 compare_exchange_weak通常是无等待的,但在出现虚假失败的情况下,compare_exchange_strong需要重试循环。

现在C++11已经存在多年了,您可以查看各种架构的汇编输出在Godbolt编译器探索器上


在内存同步方面,您需要了解缓存一致性的工作原理(我的博客可能会有所帮助)。新的CPU使用ccNUMA架构(先前是SMP)。实质上,“视图”上的内存永远不会失去同步。代码中使用的栅栏实际上并不强制刷新缓存本身,只是将正在飞行中的存储提交到缓存中的存储缓冲区,然后再加载。

如果两个核心都在缓存线中缓存了相同的内存位置,则一个核心的存储将独占缓存线(使所有其他副本无效)并标记其自己为脏。 对于非常复杂的过程,这是一个非常简单的解释

要回答您的最后一个问题,您应始终使用逻辑上需要正确的内存语义。大多数体系结构不支持程序中使用的所有组合。但是,在许多情况下,您将获得出色的优化,特别是在请求顺序已经保证而不需要栅栏的情况下(这是相当普遍的情况)。

- 对某些评论的回答:

你需要区分执行写指令和写入内存位置的含义。这是我在博客文章中试图解释的内容。当“0”被提交到0x100时,所有核心都会看到该值。写入整数也是原子的,即使没有锁定,当您写入一个位置时,如果它们想使用它,所有核心都会立即拥有该值。问题在于,为了使用该值,您可能已经将其加载到寄存器中,此后对该位置的任何更改显然不会影响寄存器。这就是为什么尽管具有高速缓存一致性的内存,仍需要互斥量或atomic<T>:编译器允许在私有寄存器中保留普通变量值。(在C++11中,这是因为非atomic变量上的数据竞争是未定义行为。)

关于矛盾的主张,通常你会看到各种各样的主张。它们是否矛盾取决于上下文中"see" "load" "execute"的确切含义。如果你向0x100写入"1",这意味着你执行了写指令还是CPU实际提交了那个值。存储缓冲区造成的差异是重新排序的一个主要原因(x86允许的唯一原因)。CPU可以延迟写入"1",但你可以确信,它最终提交了那个"1"时所有核心都能看到。栅栏通过使线程等待存储提交后再进行后续操作来控制此排序。


您写道:“如果两个内核都将同一内存位置缓存在高速缓存行中,则一个将被标记为脏,而另一个将在必要时重新加载。” ,并在您的博客中提到了类似的事情。 另一方面,在这个问题https://dev59.com/Nm855IYBdhLWcg3wq2TL中,一个用户声称:“但是,如果A对地址0x100进行普通写入“ 0”,然后B将“1”写入0x100,然后他们都在地址0x200上进行C&S-之后,他们都将看到0x200处的相同值,但A可能仍然认为0x100包含“0”。 “ 这两个说法不矛盾吗? - axel22
最后一条评论,当然假设你说的缓存行重新加载是指普通的加载和存储,而不是标记为原子操作的那些。 - axel22
1
大多数基本操作不会刷新到内存。除非您明确告诉CPU这样做,否则它通常不会刷新到内存,直到适当的时候才会这样做 - 这不太可能干扰您的程序。 - edA-qa mort-ora-y
感谢这些澄清! - axel22
“Flush” 表示清空缓存并将值写入 RAM。几乎没有程序需要这样做,除了 Spectre 代码! - curiousguy
显示剩余3条评论

1
你的整个世界观似乎有误:你的问题暗示了缓存一致性是由C++级别的内存顺序和CPU级别的栅栏或原子操作控制的。
但是,缓存一致性是物理架构中最重要的不变量之一,并且始终由内存系统提供,该系统由所有CPU和RAM的互连组成。你永远无法从在CPU上运行的代码中击败它,甚至看到它的操作细节。当然,通过直接观察RAM并在其他地方运行代码,您可能会在某个内存级别上看到旧数据:根据定义,RAM没有所有内存位置的最新值。
但是,在CPU上运行的代码无法直接访问DRAM,只能通过包括缓存在内的内存层次结构来访问,这些缓存彼此通信以维护内存的共享视图的一致性(通常使用MESI)。即使在单个核心上,写回缓存也会导致DRAM值过时,这可能会对非缓存一致DMA造成问题,但不会对从CPU读取/写入内存造成问题。
因此,问题仅存在于外部设备中,仅存在于执行非一致DMA的设备中。(DMA在现代x86 CPU上是高速缓存一致的;内存控制器内置于CPU使其成为可能)。

这样的操作会运行完整的缓存一致性协议,像i7上的内存栅栏一样同步不同处理器核心的缓存吗?

它们已经同步了。请参见内存屏障是否确保缓存一致性已完成? - 内存屏障只在运行屏障的内核内部执行本地操作,例如刷新存储缓冲区。

还是它只会同步此操作所需的内存位置?

原子操作仅适用于一个内存位置。您有其他想法吗?

在弱序CPU上,memory_order_relaxed原子增量可以避免在该增量之前使早期的加载/存储可见。但是x86的强序内存模型不允许这样做。


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