memory_order_consume到底是做什么的?

13

来自链接: 什么是load/store relaxed原子和普通变量之间的区别?

我被以下回答深深打动:

使用原子变量解决了这个问题 - 通过使用原子操作,所有线程都保证读取到最新的写入值,即使内存顺序是放松的。

今天,我阅读了下面的链接: https://preshing.com/20140709/the-purpose-of-memory_order_consume-in-cpp11/

atomic<int*> Guard(nullptr);
int Payload = 0;

线程1:

  Payload = 42;
    Guard.store(&Payload, memory_order_release);

线程2:

g = Guard.load(memory_order_consume);
if (g != nullptr)
    p = *g;

图片描述在此输入

问题: 我了解到数据依赖性可以防止相关指令被重新排序。 但我认为这显然是为了确保执行结果的正确性,不管是否存在consume-release语义。 因此,我想知道consume-release真正的作用。噢,也许它使用数据依赖性来防止指令重新排序,同时确保有效载荷的可见性

所以

如果我做到以下两点,1.防止指令重新排序2.确保非原子变量有效载荷的可见性,能否使用memory_order_relaxed获得相同的正确结果:

atomic<int*> Guard(nullptr);
volatile int Payload = 0;   // 1.Payload is volatile now

// 2.Payload.assign and Guard.store in order for data dependency
Payload = 42;               
Guard.store(&Payload, memory_order_release);

// 3.data Dependency make w/r of g/p in order
g = Guard.load(memory_order_relaxed);  
if (g != nullptr)
    p = *g;      // 4. For 1,2,3 there are no reorder, and here, volatile Payload make the value of 42 is visable.

附加内容(因为Sneftel的回答):

1.Payload = 42; 使用volatile关键字可以使得Payload的读写操作直接在主存中进行而不是缓存,所以42将被写入内存。

2.Guard.store(&Payload, 可以使用任何MO标志进行写操作); Guard是非volatile的,但是是原子的

使用原子变量可以解决这个问题-通过使用原子性,所有线程都保证读取最新的写入值,即使内存顺序被放松。

实际上,原子操作始终是线程安全的,无论内存顺序如何!内存顺序不是针对原子操作的->它针对非原子数据。

所以在Guard.store执行后,Guard.load(使用任何MO标志进行读取)可以正确获取Payload的地址。然后从内存中正确获取42。

以上代码:

1.没有数据依赖的重排效应。

2.volatile Payload没有缓存效果

3.原子Guard不存在线程安全问题

我能获得正确的值-42吗?

回到主要问题

当您使用consume语义时,基本上是要让编译器在所有这些处理器系列上利用数据依赖关系。 因此,通常简单地更改memory_order_acquire为 memory_order_consume是不足够的。您还必须确保在C++源代码级别上存在数据依赖性链。

enter image description here

“您还必须确保在C++源代码级别上存在数据依赖性链。”

我认为C++源代码级别上的数据依赖链可以防止指令自然重排序。所以memory_order_consume到底是做了什么呢?

我能否使用memory_order_relaxed来实现与上述代码相同的结果?

附加内容结束


1
这不是你的代码正在做的事情(无论哪个版本)。我不清楚为什么您认为volatile + relaxed等同于release。volatile限定符不会约束对非volatile对象(例如您的原子对象)的读取/写入。它与在C ++中编写符合多线程代码无关。 - Sneftel
1
很高兴你对我的回答印象深刻!我强烈建议忘记内存顺序consume,而在所有情况下都使用acquire。 - David Haim
在实践中,它与当前编译器的获取操作完全相同,因为证明了以一种安全和高效的方式实现ISO C++规范并利用asm依赖关系保证是太困难了。如果您想要这种效率,您必须使用mo_relaxed进行黑客攻击,并且要交叉双臂(使用使编译器难以破坏数据依赖性的代码,例如通过值分支或删除它,如果可以证明只有一个可能的值)。请参见C++11:relaxed和consume之间的区别 - Peter Cordes
1
volatile 使得 Payload 的读写操作在主存储器和高速缓存之间进行,但不会在高速缓存之间进行。 它确保存储操作被执行,而不是将值保留在寄存器中直到以后。寄存器不是高速缓存;许多人对于“在寄存器中缓存值”的措辞感到困惑。这是软件使用寄存器来保存未被修改的变量值的一种方式,但实际的 CPU 缓存是不同的(并且是一致的)。何时在多线程中使用 volatile? - 永远不要使用,但它在实践中确实有一些影响。 - Peter Cordes
还有一个链接是关于程序员对CPU缓存的错误认识的,可以参考这篇文章:程序员对CPU缓存的错误认识 - Peter Cordes
显示剩余3条评论
3个回答

12
首先,ISO C++委员会暂时不推荐使用memory_order_consume,直到他们想出编译器实际可实现的东西。几年来,编译器一直将consume视为acquire的同义词。请参见本答案底部的部分。
尽管目前没有安全可移植的ISO C ++方法可以利用,硬件仍然提供数据依赖性,因此讨论这一点很有趣。(只有mo_relaxed或手动滚动的原子以及基于对编译器优化和asm的理解的小心编码的hack,就像您尝试使用relaxed一样。但是您不需要volatile。)
“哦,也许它使用数据依赖性来防止指令重新排序,同时确保有效载荷的可见性?”
不完全是“指令重新排序”,而是内存重排序。正如您所说,如果硬件提供了依赖关系排序,则在这种情况下,理智和因果关系就足够了。C ++可移植到不支持它的机器上(例如DEC Alpha)。
通常获取Payload的可见性是通过writer中的release-store和reader中的acquire load来实现的,这样可以看到那个release-store中的值。https://preshing.com/20120913/acquire-and-release-semantics/。(因此,重复将相同的值存储到“ready_flag”或指针中并不会让读者知道它是否正在看到新的还是旧的存储。)
Release / acquire在线程之间创建了一个先于关系,保证了release-store之前writer执行的所有操作的可见性。(consume则没有,这就是为什么只有依赖的加载被排序。)
(consume是对此的优化:通过遵循某些依赖规则,让编译器利用硬件保证来避免reader中的内存屏障。)
你对CPU缓存和volatile的作用有一些误解,我在问题下面做了评论。释放-存储确保之前的非原子赋值在内存中可见。
(另外,缓存是一致的;它为所有CPU提供了一个共享的内存视图,他们可以达成一致。寄存器是线程私有的,不是一致的,这就是人们说某个值被“缓存”时的意思。寄存器不是CPU缓存,但软件可以使用它们来保存从内存中复制的内容。何时在多线程中使用volatile? - 从不,但它在实际CPU中具有一些效果,因为它们具有一致的缓存。这是自己编写mo_relaxed的不好方法。另请参见https://software.rajivprab.com/2018/04/29/myths-programmers-believe-about-cpu-caches/
实际上,在真实的CPU中,内存重排序仅在每个核心内部本地发生;缓存本身是一致的,永远不会“失去同步”。 (其他副本在存储可以变为全局可见之前被使无效)。因此,“release”只需确保本地CPU的存储以正确的顺序变为全局可见(提交到L1d缓存)。ISO C ++没有指定任何详细级别的内容,理论上可能有一个工作方式非常不同的实现。
将作者的存储设置为易失性在实践中是无关紧要的,因为后面跟随一个release-store的非原子赋值已经必须将所有东西都对其他可能执行acquire-load并与该release存储进行同步的线程可见。在纯ISO C ++中,这在纸面上是无关紧要的,因为它不能避免数据竞争UB。
(当然,整个程序优化理论上可以看出没有获取或消耗加载会加载此存储,并优化掉释放属性。但编译器目前甚至不会本地优化原子操作,并且从不尝试执行那种整个程序分析。因此,写入函数的代码生成将假定可能存在读取器与任何给定的释放或seq_cst排序存储同步。)

memory_order_consume到底是做什么的?

mo_consume的一个作用是确保编译器在底层硬件不自然/免费提供依赖关系排序的实现上使用障碍指令。实际上,这只发生在DEC Alpha上。 CPU中的相关负载重排 / C11中的Memory order consume用法

你的问题与C ++11:memory_order_relaxed和memory_order_consume之间的区别几乎相同-请参见那里的答案,了解有关使用volatile和relaxed进行无意义尝试的问题。 (我主要回答标题问题。)

它还确保编译器在执行进入不知道此值所携带的数据依赖关系的代码之前的某个点使用屏障(即在声明中函数参数没有[[carries_dependency]]标签)。这样的代码可能会将x-x替换为常数0并进行优化,从而丢失数据依赖性。但是知道依赖关系的代码必须使用类似于sub r1,r1,r1指令来获取具有数据依赖性的零。

对于您的用例(其中relaxed在Alpha以外的ISA上实际可行),这种情况不会发生,但mo_consume的理论设计允许各种需要与编译器通常生成的代码不同的代码生成。这是使其有效实现如此困难的一部分,因此编译器只需将其提升为mo_acquire

问题的另一部分是它要求代码到处都充斥着kill_dependency和/或[[carries_dependency]],否则您最终将在函数边界处得到一个屏障。这些问题导致ISO C ++委员会暂时不鼓励使用consume


顺便提一下:

无论volatile如何,使用release + consume的示例代码都是安全的。在实践中,使用release store + relaxed load可以在大多数编译器和大多数ISA上保证安全,尽管ISO C++对该代码的正确性没有任何说明。但是在当前编译器的状态下,这是一些代码所做的hack(例如Linux内核的RCU)。

如果您需要那种读取端扩展的级别,您将不得不超出ISO C++所保证的范围。这意味着您的代码将必须假设编译器的工作方式(并且您正在运行一个“正常”的ISA,而不是DEC Alpha),这意味着您需要支持一些编译器(也许还有ISAs,尽管很少有多核ISAs)。 Linux内核只关心少数编译器(主要是最近的GCC,也包括clang),以及它们有内核代码的ISA。


1
@breaker00:不,存在没有依赖排序保证的硬件并不是我们需要“consume”的原因。即使没有这个,你仍然需要它来控制代码生成,并确保编译器不会优化掉依赖项。(C++规则需要正式和精确;像“只要你不做编译器可以优化的事情”这样的东西不够具体)。 - Peter Cordes
1
同样的重要性在于理解 C++ 内存模型是独立于硬件内存模型的。即使编译针对例如 x86 的东西(其中甚至 acquire 也是免费的,而不仅仅是 consume),优化都是基于 C++ 内存排序规则而非硬件的。尽管硬件必须保持按顺序运行的幻象,但针对 x86 的编译器仍可以在编译时重新排序 .load(mo_relaxed) - Peter Cordes
2
同样地,将数据依赖性优化为分支对于松散的情况是允许的,例如像 int idx = x.load(relaxed); int *p = table[idx]; q = *p; 这样的代码,其中有一个2元素表:编译器可以只在0和1之间进行分支并选择一个,从而失去了依赖性。因此,ISO C++需要一些方式来禁止编译器这样做,同时仍然允许对不依赖于数据依赖性排序的代码进行完全灵活的优化。因此,在正式语言规范中,mo_consume 以某种形式是必要的,以避免所有内容都带有依赖性并禁止分支。 - Peter Cordes
1
我纠正了对 consume 最初目的的想法。 这样说是否正确? 如果使用 consume,这意味着我希望编译器确保数据依赖的正确性。不要优化以删除我需要的数据依赖关系。即使在像 DEC Alpha 这样的平台上,请添加一个 fence 以确保相同的正确数据依赖关系。对于没有使用 consume 的其他情况,等同于告诉编译器我并不太关心数据依赖关系的正确性,可以进行你认为正确的优化。 - breaker00
1
感谢像你这样的人,因为你无法入睡。这样那些迷失方向的人就可以更早地上床睡觉了。 - breaker00
显示剩余2条评论

3
  1. volatile 关键字在 C/C++ 中与多线程无关,它的顺序可见性只出现在单线程程序中,通常用于告诉编译器不要优化掉该变量。它与 Java/C# 中的 volatile 是不同的。

  2. release/consume 关注的是数据依赖性,并且可能构建一个依赖链(可以使用 kill_dependency 打破以避免后续不必要的屏障)。

  3. release/acquire 形成成对的 synchronize-with/inter-thread happens-before 关系。

在您的情况下,release/acquire 将形成预期的 happens-before 关系。release/consume 也可以工作,因为 *g 依赖于 g

但请注意,当前的编译器把 consume 视为 acquire 的同义词,因为实现效率太低。参见另一个回答


是的,所有这些要点都是正确的,但在这里释放/消耗安全的(无论volatile如何),即使在旧编译器上,它们不仅将consume提升为acquire。(consume暂时被弃用,直到C++委员会提出一个更好的consume,可以完全安全且高效地实现,而不会用[[carries_dependency]]标签感染每个人的代码。)g是看到release-store的consume的结果,因此*g在加载g之后排序。 - Peter Cordes
1
(在纸面上)release/relaxed 不安全,但在大多数 ISA 上“可能”会工作,因为编译器将生成具有数据依赖性的汇编代码,并且除 Alpha 外的所有 (?) ISA 都保证依赖关系排序。C++11:memory_order_relaxed 和 memory_order_consume 的区别 - 只有在您了解情况并且只关心有限的一组编译器时才在生产代码中执行此操作。例如,Linux 内核使用其手动滚动的 asm 原子 / 屏障(或缺乏原子性 / 屏障)来执行 RCU,但仅关心 gcc / clang,而不是 ISO C。 - Peter Cordes
是的,你说得对,在这里使用 consume 是有效的。请随意编辑我的答案,谢谢。 - Harold

-1

问题在于,答案并不完全正确,因为有一些细微差别。

使用原子变量可以解决这个问题-通过使用原子变量,所有线程都保证读取最新的写入值,即使内存顺序是放松的。

他们确实读取了“最新的写入值”,但是使用“放松”的内存顺序时,指令的顺序可以被重新排列。

因此,如果您说写DoSomething(); x = y.load(relaxed);然后在编译后,放松的负载可能会在DoSomething();之前排序。假设例程花费了相当长的时间,那么x的值可能与y的最新值相差很大。

使用内存顺序“consume”禁止指令重排,因此不会发生这样的问题。


1
我认为你没有完整地阅读我的回答,因为我已经涵盖了ooo执行的主题。 - David Haim
@DavidHaim,好吧,这就是内存顺序“consume”的唯一目的。我查看了您引用的文章,不能确定,但他们提出的“consume”使用听起来非常错误。通过“consume”读取指针g并不能保证访问指针数据将被正确执行。即使CPU可以以某种方式确保依赖项加载 - 我怀疑 - 编译器仍然可能通过进行假设而搞砸它。也许,事实上“consume”有效的原因是编译器编写者对此指令感到困惑,并将其实现为“aquire+release”。 - ALX23z
1
我不是楼主。我是你说答案不完全正确的那个人(暗示我没有提到重新排序的事情,但我确实提到了)。 - David Haim
1
@ALX23z:是的,如果您使用release + relaxed,编译器可能会破坏一些东西。但是,如果汇编代码在第一次加载结果上具有第二个地址的依赖关系,那么CPU实际上很难违反因果关系并在拥有该加载地址之前知道从哪里加载。除了DEC Alpha之外,所有(?)现代ISA都保证纸面上的依赖关系排序,因此它不是可选项或运气,而是由CPU供应商保证的。 (但是正如我所说,对于标准的OoO执行机器来说,它基本上是免费的;只有独立的工作可以被重新排序。) - Peter Cordes
1
DEC Alpha CPU的一些型号如何违反因果关系/依赖顺序的实际机制非常模糊,但这是一个很好的例子,说明通常不安全假设“没有CPU设计能够违反我想要做出的这种假设”。有关详细信息,请参见CPU中的相关负载重新排序,有关其他评论,请参见Linus Torvalds关于Alphas的引用:C11中的内存顺序使用。对于负载的值预测可能会破坏它,但目前还没有真正的CPU这样做。 - Peter Cordes
显示剩余4条评论

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