std::atomic::fetch_add 是 x86-64 上的序列化操作吗?

is std::atomic::fetch_add a serializing operation on x86-64?

考虑以下代码:

std::atomic<int> counter;

/* otherStuff 1 */
counter.fetch_add(1, std::memory_order_relaxed);
/* otherStuff 2 */

x86-64(比如不到 5 年的旧架构)中是否有允许 otherStuff 1 和 2 在 fetch_add 中重新排序的指令,或者它是否会一直序列化?

编辑:

这似乎是由 "is lock add a memory barrier on x86 ?" 总结的,但似乎不是,尽管我不确定在哪里可以找到相关参考。

当使用 std::memory_order_relaxed 时,唯一的保证是操作是原子的。编译器或 CPU.

可以随意对操作周围的任何内容重新排序

来自https://en.cppreference.com/w/cpp/atomic/memory_order

Relaxed operation: there are no synchronization or ordering constraints imposed on other reads or writes, only this operation's atomicity is guaranteed (see Relaxed ordering below)

首先让我们看看使用std::memory_order_relaxed.
时允许编译器做什么 如果 otherStuff 1/2 和原子操作之间没有依赖关系,它当然可以重新排序语句。例如:

g = 3;
a.fetch_add(1, memory_order_relaxed);
g += 12;

clang++ 生成以下程序集:

lock   addl [=11=]x1,0x2009f5(%rip)        # 0x601040 <a>
movl   [=11=]xf,0x2009e7(%rip)             # 0x60103c <g>

这里 clang 冒昧地用原子 fetch_add 操作重新排序 g = 3,这是一个合法的转换。

使用std::memory_order_seq_cst时,编译器输出变为:

movl   [=12=]x3,0x2009f2(%rip)        # 0x60103c <g>
lock   addl [=12=]x1,0x2009eb(%rip)   # 0x601040 <a>
addl   [=12=]xc,0x2009e0(%rip)        # 0x60103c <g>

语句的重新排序不会发生,因为不允许编译器这样做。 读取-修改-写入 (RMW) 操作的顺序一致排序既是释放操作也是获取操作,因此,在编译器和 CPU 级别上不允许对语句进行(可见的)重新排序。

您的问题是,在 X86-64std::atomic::fetch_add 上,使用宽松排序是否是序列化操作..
答案是:是的,如果你不考虑编译器重新排序的话。

X86 架构上,RMW 操作总是刷新存储缓冲区,因此实际上是一个序列化和顺序一致的操作。

你可以说,在 X86 CPU 上,每个 RMW 操作:

  • 在它之前的内存操作是释放操作,在它之后的内存操作是获取操作。
  • 在所有线程观察到的单个总订单中变得可见。

目标架构

On the X86 architecture, an RMW operation always flushes the store buffer and therefore is effectively a serializing and sequentially consistent operation.

我希望人们不要再这么说了。

该语句甚至没有意义,因为没有 "sequentially consistent operation" 这样的东西,因为 "sequential consistency" 不是任何操作的 属性。顺序一致的执行是最终结果是交错操作给出该结果的执行。

关于这些 RMW 操作可以说些什么:

  • 在 RMW 的 R 或 W 可见之前,RMW 之前的所有操作必须是全局可见的
  • 并且在RMW可见之前RMW可见之后没有任何操作。

也就是前面的部分,RMW,后面的部分是运行顺序的。换句话说,RMW前后都有一个full fence

这是否会导致整个程序的顺序执行取决于程序所有全局可见操作的性质。

可见性与执行顺序

就可见性而言。我不知道这些处理器是否会在 RMW 之后尝试推测性地执行代码,但要符合正确性要求,即如果与并行执行有副作用的冲突会回滚操作(这些细节对于不同的供应商和给定家庭中的几代人,除非明确指定)。

您的问题的答案可能会有所不同

  • 你需要保证副作用集的正确性(如顺序一致性要求),
  • 或保证基准可靠,
  • 或那个比较时间 CPU 版本独立:保证不同执行时间比较的结果(对于给定的 CPU)。

高级语言与 CPU 功能

题目是"is std::atomic::fetch_add a serializing operation on x86-64?"一般形式:

"does OP provide guarantees P on ARCH"

哪里

  • OP 是高级语言的高级操作
  • P 是想要的 属性
  • ARCH 是特定的 CPU 或编译器目标

通常,规范的答案是:这个问题没有意义,OP 是高级别和目标独立的。 此处存在低 level/high 级别不匹配。

编译器受语言标准(或者更确切地说是其最合理的解释)、文档扩展、历史...的约束,而不是目标体系结构的标准,除非该功能是高级语言的低级透明特性.

在 C/C++ 中获得低级语义的规范方法是使用可变对象和可变操作。

在这里您必须使用 volatile std::atomic<int> 才能提出有关架构保证的有意义的问题。

当前代码生成

您的问题的有意义的变体将使用此代码:

volatile std::atomic<int> counter;

/* otherStuff 1 */
counter.fetch_add(1, std::memory_order_relaxed);

该语句将生成一个原子 RMW 操作,在这种情况下 "is a serializing operation" 在 CPU 上:之前执行的所有操作,在汇编代码中,都在 RMW 启动之前完成; RMW 之后的所有操作等到 RMW 完成后开始(就可见性而言)。

然后您需要了解 volatile 语义的不愉快之处:volatile 仅适用于这些 volatile 操作,因此您仍然无法获得有关其他操作的一般保证。

无法保证易失性 RMW 操作之前的高级 C++ 操作在汇编代码中先排序。你需要一个 "compiler barrier" 来做到这一点。这些障碍不可携带。 (这里不需要,因为无论如何这是一种愚蠢的方法。)

但是如果你想要那个保证,你可以使用:

  • 一次释放操作:确保之前的全局可见操作完成
  • 获取操作:确保以下全局可见操作不会在
  • 之前启动
  • 对多个线程可见的对象的 RMW 操作。

所以为什么不让你的RMW操作ack_rel?那么它甚至不需要是volatile的。

处理器系列中可能的 RMW 变体

Is there an instruction in x86-64 (say less than 5 years old architectures) that would

指令集的潜在变体是另一个子问题。供应商可以引入新的指令,以及在 运行 时间测试其可用性的方法;编译器甚至可以生成代码来检测它们的可用性。

任何遵循现有传统 (1) 通常 内存操作强排序的任何 RMW 功能都必须尊重传统:

  • Total Store Order:所有存储操作都是有序的,隐式隔离;换句话说,每个核心都有一个严格用于非推测存储操作的存储缓冲区,不会重新排序,也不会在内核之间共享;
  • 每一次store都是一次release操作(针对之前正常的内存操作);
  • 推测启动的加载按顺序完成,并在完成时进行验证:取消缓存中随后被破坏的位置的任何早期加载,并使用最近的值重新启动计算;
  • 加载是获取操作。

那么任何新的(但传统的)RMW 操作必须既是获取操作又是释放操作。

(未来可能增加的假想RMW操作的例子是xmultxdiv。)

但那是未来学,在未来添加较少顺序的指令不会违反任何安全不变量,除非可能针对基于时序的侧信道 Spectre 类攻击,我们不知道如何建模和推理反正一般。

即使是现在,这些问题的问题在于需要缺席证明,为此我们需要了解 CPU 家族的每个变体。这并不总是可行的,而且,如果您在高级代码中使用正确的顺序,是不必要的,如果您不这样做,则没有用

(1) 保证内存操作的传统是 CPU 设计中的准则,不保证任何功能操作:根据定义,尚不存在的操作无法保证其语义,除了内存完整性保证,即保证未来的操作不会破坏先前建立的特权和安全保证(将来创建的任何非特权指令都不能访问未映射的内存地址...)。