memory_dorder_seq_cst和memory_dorder_acq_rel有何不同

How do memory_order_seq_cst and memory_order_acq_rel differ?

本文关键字:memory dorder rel 何不同 cst seq acq      更新时间:2023-10-16

存储是释放操作,加载是两者的获取操作。我知道memory_order_seq_cst是为了给所有操作强加一个额外的总排序,但我没有构建一个例子,如果所有的memory_order_seq_cst都被memory_order_acq_rel取代,情况就不是这样了。

我是错过了什么,还是区别只是文档效应,即如果不想玩更放松的模型,应该使用memory_order_seq_cst,而在约束放松的模型时使用memory_order_acq_rel

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始终递增。

在x86等ISAs上,原子映射到屏障,实际机器模型包括存储缓冲区:

  • seq_cst存储需要刷新存储缓冲区,因此该线程稍后的读取将延迟到存储全局可见之后

  • acquirerelease必须刷新存储缓冲区。正常的x86加载和存储本质上具有acq和rel语义。(seq_cst加上具有存储转发的存储缓冲区。)

    但是x86原子RMW操作总是被提升为seq_cst,因为x86 asm lock前缀是一个完整的内存屏障。其他ISA可以在asm中执行松弛或acq_rel RMW,存储端可以对以后的存储执行有限的重新排序。(但不是以使RMW看起来非原子的方式:为了排序,原子读修改写是一个操作还是两个操作?)


https://preshing.com/20120515/memory-reordering-caught-in-the-act是seq_cst存储和普通发布存储之间区别的一个有指导意义的示例(实际上是mov+mfence,而不是x86 asm中的普通mov。实际上,xchg是在大多数x86 CPU上进行seq_cst存储的更有效的方法,但GCC确实使用mov+mfence


有趣的事实:AArch64的LDAR获取加载指令实际上是一个顺序-获取,与STLR有特殊的交互。直到ARMv8.3 LDAPR才能arm64执行简单的获取操作,这些操作可以使用早期版本和seq_cst存储(STLR)进行重新排序。(seq_cst加载仍然使用LDAR,因为它们需要与STLR的交互来恢复顺序一致性;seq_cstrelease存储都使用STLR)。

使用STRR/LDAR,您可以获得顺序一致性,但只需在下一个LDAR之前耗尽存储缓冲区,而不是在其他操作之前在每个seq_cst存储之后立即耗尽。我认为真正的AArch64硬件确实是这样实现的,而不是在提交STLR之前简单地耗尽存储缓冲区。

通过使用LDAR/STRR将rel或acq_rel增强为seq_cst并不需要昂贵,除非你存储了一些东西,然后seq_cst加载了其他东西。那么它和x86一样糟糕。

其他一些ISAs(如PowerPC)有更多的屏障选择,可以比mo_seq_cst更便宜地增强到mo_relmo_acq_rel,但它们的seq_cst不能像AArch64那样便宜;seqcst商店需要一个完整的屏障。

因此,AArch64是seq_cst存储当场耗尽存储缓冲区的规则的例外,无论是使用特殊指令还是之后的屏障指令。ARMv8是在C++11/Java/等之后设计的,这并非巧合。基本上,seq_cst是无锁原子操作的默认值,因此使它们高效非常重要。在CPU架构师有几年的时间来考虑提供屏障指令的替代方案,或者只是获取/释放与放松的加载/存储指令。

尝试使用获取/释放语义构建Dekkers或Petersons算法。

这不会起作用,因为获取/发布语义没有提供[StoreLoad]围栏。

在Dekkers算法的情况下:

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

如果没有[StoreLoad]围栏,商店可能会跳到负载前面,然后算法就会崩溃。2个线程同时会看到另一个锁是空闲的,设置自己的锁并继续。现在,在关键部分中有2个线程。

仍然使用memory_order中的定义和示例。但在存储中用memory_order_release和在加载中用memory_order_aquire替换memory_order_seq_cst。

Release Acquire排序保证在一个线程中的存储成为加载线程中可见的副作用之前发生的一切。但在我们的示例中,在存储在thread0和thread1中之前,不会发生任何事情。

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,断言触发。

然而,如果memory_order_seq_cst进入图片,它将为所有被标记的原子操作建立一个单一的总修改顺序。因此,在线程2中,x.load然后y.load;在thread3中,y.load和x.load是肯定的。