memory_order_seq_cst和memory_order_acq_rel有何区别?

64

存储是对于两者都是释放操作,加载则是对于两者都是获取操作。我知道memory_order_seq_cst旨在为所有操作强制实施额外的全序,但我无法构建一个示例,在其中用memory_order_acq_rel替换所有memory_order_seq_cst而不是这种情况。

我错过了什么,还是只是文档效应的差异,即如果不打算使用更轻松的模型并使用memory_order_acq_rel来约束轻松的模型,则应该使用memory_order_seq_cst

4个回答

60

http://en.cppreference.com/w/cpp/atomic/memory_order有一个很好的例子在底部, 仅适用于memory_order_seq_cst。基本上,memory_order_acq_rel提供相对于原子变量的读写顺序,而memory_order_seq_cst提供全局的读写顺序。也就是说,顺序一致的操作在所有线程中以相同的顺序可见。

这个例子可以归结为:

bool x= false;
bool y= false;
int z= 0;

a() { x= true; }
b() { y= true; }
c() { while (!x); if (y) z++; }
d() { while (!y); if (x) z++; }

// kick off a, b, c, d, join all threads
assert(z!=0);

z上的操作由两个原子变量保护,而不是一个,因此您不能使用获取-释放语义来强制执行始终递增z


2
@acidzombie24,即使在这种情况下,“z”也将是2。 - MSN
9
使用ack_rel,c()可以感知在a()x=true;发生在b()y=true;之前,同时,由于缺乏“全局排序”,d()也可以感知y=true;发生在x=true;之前。特别地,c()可以感知x==truey==false同时成立,而d()可以感知y==truex==false同时成立。因此,z可能既不会被c()增加,也不会被d()增加。使用seq_cst时,如果c()感知到x=true;y=true;之前发生,那么d()也会这样感知。 - nodakai
1
@MSN 你的意思是 int z=0,而不是 bool z=0 - nodakai
2
@nodakai,您的解释是准确的,但我认为短语“发生在之前”可能会引起误解,因为获取-释放的问题的关键在于两个写操作都不会“发生在之前”。 - jhoffman0x
4
这个例子使用的是纯加载和纯存储,而不是任何实际使用 std::memory_order_acq_rel 的原子读写修改操作。在原子读改写操作中,加载和存储是绑定在一起的,因为它们是一个原子操作。我不确定像 .fetch_add.compare_exchange_weak 这样的操作什么时候(如果有时候)acq_rel 会与 seq_cst 不同。 - Peter Cordes
显示剩余10条评论

17
在像x86这样的ISA上,原子操作映射到屏障,并且实际的机器模型包括存储缓冲区。
  • seq_cst存储需要刷新存储缓冲区,因此该线程的后续读取将延迟到存储全局可见之后。(至少在下一个seq_cst加载之前,不一定在非原子或非SC原子加载或存储之前。)

    有关AArch64对此的硬件支持,请参见下文;大多数其他ISA只需等待存储缓冲区在SC存储和RMW之后排空。一种C++到汇编的替代映射是在每个seq_cst加载之前排空存储缓冲区,而不是在存储之后,但我们更需要廉价的加载而不是廉价的存储。(多个核心可以并行加载同一对象;存储必然导致争用以获得缓存行的独占所有权。)

  • acquirerelease不必刷新存储缓冲区。普通的x86加载和存储基本上具有acq和rel语义,在x86汇编中是免费的。(每个核心对一致性缓存的访问按程序顺序进行,除了具有存储转发的存储缓冲区。)

    但是x86原子RMW操作总是被“提升”为seq_cst,因为x86汇编的lock前缀是一个完全的内存屏障。其他ISA可以在汇编中执行松散或acq_rel的RMW操作,存储方面可以对其他对象的后续存储或加载进行有限的重新排序。(但不能以使RMW看起来非原子的方式进行重新排序:为了排序,原子读取-修改-写是一个操作还是两个操作?


https://preshing.com/20120515/memory-reordering-caught-in-the-act 是一个很有教育意义的例子,展示了seq_cst存储和普通release存储之间的区别。(实际上,在x86汇编中,它是mov + mfence与普通的mov的区别。在实践中,xchg是大多数x86 CPU上执行seq_cst存储更高效的方式,但GCC确实使用mov+mfence
有趣的事实:AArch64的LDAR acquire-load指令实际上是一个sequential-acquire,与STLR有特殊的交互。只有在ARMv8.3 LDAPR之后,arm64才能执行可以与之前的release和seq_cst存储(STLR)重新排序的plain acquire操作。(seq_cst加载仍然使用LDAR,因为它们需要与STLR进行交互以恢复sequential consistency;seq_cst和release存储都使用STLR)。
使用STLR / LDAR,您可以获得sequential consistency,但只需要在下一个LDAR之前排空存储缓冲区,而不是在每个seq_cst存储之后立即排空。我认为真正的AArch64硬件是以这种方式实现的,而不仅仅是在提交STLR之前排空存储缓冲区。
通过使用LDAR / STLR将rel或acq_rel加强为seq_cst并不需要太多的开销,除非您seq_cst存储某些内容,然后seq_cst加载其他内容。然后它就和x86一样糟糕。
一些其他的ISA(如PowerPC)有更多的屏障选择,并且可以比mo_seq_cst更便宜地加强mo_rel或mo_acq_rel,但它们的seq_cst不能像AArch64那样便宜;seq_cst存储需要一个完整的屏障。
因此,AArch64是一个例外,即seq_cst存储不会立即清空存储缓冲区,要么使用特殊指令,要么在之后使用屏障指令。这不是一个巧合,ARMv8的设计是在C++11 / Java /等等已经基本确定seq_cst作为无锁原子操作的默认值之后进行的,因此使其高效是很重要的。而且,在CPU架构师有几年时间来考虑提供屏障指令或者仅提供acquire/release vs. relaxed的加载/存储指令的替代方案之后。

但是x86原子RMW操作总是会被提升为seq_cst,因为x86 asm锁前缀是一个完整的内存屏障。你为什么说它们被“提升”了呢?此外,执行器可能会规范性地加载值(通常情况下)并进行计算,只要它稍后安全地重新加载(锁定加载)即可;如果计算速度很快,那可能并不重要,但仍然有可能发生。(我想这些事情在英特尔为现有设计以纯描述方式记录,而不是为未来的设计记录。) - curiousguy
1
@curiousguy:无论如何,如果一个假设的实现想要尝试早期加载以为实际实现RMW设置更便宜的原子交换,它只能这样推测并在错误推测时回滚(如果在结构上允许加载之前该行发生了变化)。常规加载已经按此方式工作,以获得性能同时保持强加载排序。(请参见machine_clears.memory_ordering性能计数器:Why flush the pipeline for Memory Order Violation caused by other logical processors? - Peter Cordes
2
@PeterCordes - 我甚至认为这不是假设:我认为这就是当前Intel x86上原子操作的实现方式(有时)。也就是说,它们在一个乐观的锁定状态下加载缓存行,在RMW的“前端”执行(包括ALU操作),然后在RMW的“后端”中,通过执行-at-retire操作验证所有排序是否正确。当位置没有争用时,这种方法非常有效。如果这经常失败,预测器将切换到在退休时完成整个过程的模式,这会导致管道中的更大气泡(因此“有时”)。 - BeeOnRope
1
@DanielNitzan:RMW实际上是一个载入和存储;没有操作可以在它们之间对相同的位置进行,以确保RMW的原子性,但对其他位置的操作实际上是可以重叠的。存储部分具有释放语义,在某些情况下,它实际上可以与稍后到达的存储到另一位置重排序。(或者与后续的加载重排序,但我链接的问答显示了使用两个存储器的实验。) - Peter Cordes
1
@DanielNitzan:x86 没有这种效应;它的 RMW 是完全内存屏障,因此 load+store 保持完全粘在核心操作的全局可见顺序中。这在 LoadLoad 和 StoreStore 排序保证方面是有些必要的。如果 x86 上存在 acq_rel RMWs,存储端可能会与后续加载重新排序,但不会与存储器重新排序。但这是由于后续的存储本身就是 release 操作,而 x86 一般只允许 StoreLoad 重新排序。对于早期的存储也是如此,但不允许将重新排序与 x86 上假设的 acq_rel RMW 的负载侧重排列。 - Peter Cordes
显示剩余13条评论

5

尝试仅使用acquire/release语义构建Dekkers或Peterson算法。

这不会起作用,因为Acquire/Release语义不能提供[StoreLoad]屏障。

对于Dekkers算法:

flag[self]=1 <-- STORE
while(true){
    if(flag[other]==0) { <--- LOAD
        break;
    }
    flag[self]=0;
    while(turn==other);
    flag[self]=1        
}

没有 [StoreLoad] 栅栏,存储操作可能会跳过加载操作导致算法失败。同时,两个线程可能会同时看到另一个锁是空闲的,设置自己的锁并继续执行。这样,你就会有两个线程处于临界区。


"如果没有[StoreLoad]栅栏,存储操作可能会在加载操作之前执行。在您的代码中,您标记了一个已经在加载操作之前的存储操作。您是想说加载操作可能在存储操作之前执行吗?" - dyp
你是对的。负载可以跳到商店前面。 - pveentjer

4
仍使用自memory_order的定义和示例,但在存储中用memory_order_release代替memory_order_seq_cst,在加载中用memory_order_acquire代替。
释放-获取排序保证了在一个线程中发生在store之前的所有事情都成为在执行load的线程中可见的副作用。但在我们的示例中,无论是在thread0还是thread1中,都没有发生store之前的任何事情。
x.store(true, std::memory_order_release); // thread0

y.store(true, std::memory_order_release); // thread1

此外,如果没有memory_order_seq_cst,线程2和线程3的顺序是不能保证的。你可以想象它们会变成:
if (y.load(std::memory_order_acquire)) { ++z; } // thread2, load y first
while (!x.load(std::memory_order_acquire)); // and then, load x

if (x.load(std::memory_order_acquire)) { ++z; } // thread3, load x first
while (!y.load(std::memory_order_acquire)); // and then, load y

因此,如果在thread0和thread1之前执行了thread2和thread3,那么x和y都保持为false,因此++z永远不会被触及,z保持为0并且assert失败。

然而,如果使用memory_order_seq_cst,则建立所有标记为这样的原子操作的单个总修改顺序。 因此,在thread2中,x.load然后y.load; 在thread3中,y.load然后x.load是确定的事情。


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