小编典典

num++ 可以是“int num”的原子吗?

all

一般来说, for int num, num++(or ++num) 作为一个读-修改-写操作, 不是原子
的。但我经常看到编译器,例如GCC为其生成以下代码(试试这里):

void f()
{
  int num = 0;
  num++;
}



f():
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        add     DWORD PTR [rbp-4], 1
        nop
        pop     rbp
        ret

由于对应的第 5 行num++是一条指令,我们可以断定在这种情况下它num++ 是原子的吗?

如果是这样,
这是否意味着这样生成的num++可以在并发(多线程)场景中使用而没有任何数据竞争的危险(例如,我们不需要制造它std::atomic<int>并施加相关的成本,因为它是无论如何都是原子的)?

更新

请注意,这个问题 不是 增量是否 原子的(它不是,那是问题的开场白)。它是否 可以
在特定场景中,即是否可以在某些情况下利用单指令性质来避免lock前缀的开销。而且,正如接受的答案在关于单处理器机器的部分中提到的那样,


阅读 85

收藏
2022-08-08

共1个答案

小编典典

这绝对是 C++
定义为导致未定义行为的数据竞赛,即使一个编译器碰巧生成了在某些目标机器上执行您希望的代码。您需要使用以获得可靠的结果,但如果您不关心重新排序,则std::atomic可以使用它。memory_order_relaxed请参阅下面的一些示例代码和使用fetch_add.


但首先,问题的汇编语言部分:

由于 num 是一条指令 ( add dword [num], 1),我们可以得出结论 num 在这种情况下是原子的吗?

内存目标指令(纯存储除外)是在多个内部步骤中发生的读-修改-写操作 。没有修改架构寄存器,但 CPU
在通过其ALU发送数据时必须在内部保存数据。即使是最简单的
CPU,实际的寄存器文件也只是数据存储的一小部分,锁存器将一个阶段的输出作为另一阶段的输入,等等。

来自其他 CPU 的内存操作可以在加载和存储之间变得全局可见。即在一个循环中运行的两个线程add dword [num], 1会踩到彼此的商店。。在两个线程中的每一个增加 40k 之后,在真正的多核 x86硬件上,计数器可能只增加了约 60k(不是 80k)。


“原子”,来自希腊词,意思是不可分割的,意味着没有观察者可以
操作视为单独的步骤。对所有位同时在物理/电气上同时发生只是实现加载或存储的一种方法,但这对于 ALU 操作甚至是不可能的。
在我对Atomicity
on x86
的回答中,我详细介绍了纯负载和纯存储,而这个答案侧重于读取-修改-写入。

lock前缀可以应用于许多读取-修改-
写入(内存目标)指令,以使整个操作相对于系统中所有可能的观察者(其他内核和 DMA 设备,而不是连接到 CPU
引脚的示波器)具有原子性。这就是它存在的原因。

原子lock add dword [num], 1 也是 如此。运行该指令的 CPU 内核将在其私有 L1
高速缓存中将高速缓存行固定在已修改状态,从加载从高速缓存读取数据直到存储将其结果提交回高速缓存。根据MESI
缓存一致性协议
(或多核 AMD/英特尔
CPU)。因此,其他核心的操作似乎发生在之前或之后,而不是期间。

如果没有lock前缀,另一个核心可以获取缓存行的所有权,并在我们加载之后但在我们的存储之前对其进行修改,以便其他存储在我们的加载和存储之间变得全局可见。其他几个答案都错了,并声称如果没有lock你会得到同一缓存行的冲突副本。这在具有一致缓存的系统中永远不会发生。

(如果locked
指令在跨越两个高速缓存行的内存上运行,则需要做更多的工作才能确保对对象的两个部分的更改在传播到所有观察者时保持原子性,因此没有观察者可以看到撕裂。CPU
可能必须锁定整个内存总线,直到数据到达内存。不要错位你的原子变量!)

请注意,lock前缀还将指令变成完整的内存屏障(如MFENCE),停止所有运行时重新排序,从而提供顺序一致性。(参见Jeff
Preshing 的优秀博文
。他的其他博文也都非常出色,并且清楚地解释了 许多
关于无锁编程的好东西,从 x86 和其他硬件细节到 C++ 规则。)


在单处理器机器上,或在单线程进程中 ,一条RMW指令实际上 原子的,没有lock前缀。其他代码访问共享变量的唯一方法是 CPU
进行上下文切换,这不能在指令中间发生。因此,plaindec dword [num]可以在单线程程序及其信号处理程序之间进行同步,或者在单核机器上运行的多线程程序中进行同步。


回到 C++:

num++如果不告诉编译器您需要将其编译为单个读取-修改-写入实现,那么使用它是完全虚假的:

;; Valid compiler output for num++
mov   eax, [num]
inc   eax
mov   [num], eax

如果您使用 later
的值,这很有可能num:编译器将在递增后将其保存在寄存器中。因此,即使您自己检查num++编译方式,更改周围的代码也会对其产生影响。

(如果以后不需要该值,inc dword [num]则首选该值;现代 x86 CPU 将运行内存目标 RMW
指令至少与使用三个单独指令一样有效。有趣的事实:gcc -O3 -m32 -mtune=i586实际上会发出
this
,因为
(Pentium) P5 的超标量管道没有’不要像 P6 和更高版本的微架构那样将复杂的指令解码为多个简单的微操作。有关更多信息,请参阅Agner Fog
的指令表/微架构指南
,以及x86标签 wiki 以获得许多有用的链接(包括英特尔的 x86 ISA 手册,它们是以 PDF 格式免费提供))。


不要将目标内存模型 (x86) 与 C++ 内存模型混淆

允许
编译时重新排序
。使用
std::atomic 获得的另一部分是对编译时重新排序的控制,以确保您num++仅在其他一些操作之后才成为全局可见的。

经典示例:将一些数据存储到缓冲区中以供另一个线程查看,然后设置一个标志。即使 x86
确实免费获取加载/释放存储,您仍然必须告诉编译器不要使用flag.store(1, std::memory_order_release);.

您可能期望此代码将与其他线程同步:

// int flag;  is just a plain global, not std::atomic<int>.
flag--;           // Pretend this is supposed to be some kind of locking attempt
modify_a_data_structure(&foo);    // doesn't look at flag, and the compiler knows this.  (Assume it can see the function def).  Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;

但它不会。编译器可以自由移动flag++函数调用(如果它内联函数或知道它不查看flag)。然后它可以完全优化掉修改,因为flagis not
even volatile.

(不,C++volatile不是 std::atomic 的有用替代品。std::atomic
确实使编译器假设内存中的值可以异步修改,类似于volatile,但除此之外还有更多。(实际上有volatile int 与 std::atomic
之间的相似之处与 mo_relaxed用于纯加载和纯存储操作,但不适用于
RMW。此外,volatile std::atomic<int> foo不一定与 相同std::atomic<int> foo,尽管当前编译器不优化原子(例如 2 back-to -back 存储相同的值)所以 volatile atomic 不会改变代码生成。)

将非原子变量上的数据竞争定义为未定义行为是让编译器仍然可以将负载和接收存储提升到循环之外,以及多个线程可能引用的许多其他内存优化。(有关 UB
如何启用编译器优化的更多信息,请参阅此 LLVM 博客。)


正如我所提到的,x86lock前缀是一个完整的内存屏障,因此
usingnum.fetch_add(1, std::memory_order_relaxed);在 x86
上生成与(默认为顺序一致性)相同的代码num++,但在其他架构(如 ARM)上效率更高。即使在 x86 上,relaxed 也允许更多的编译时重新排序。

这就是 GCC 在 x86 上实际所做的,对于一些对std::atomic全局变量进行操作的函数。

在Godbolt
编译器浏览器
上查看源代码
+ 汇编语言代码的格式。您可以选择其他目标体系结构,包括 ARM、MIPS 和 PowerPC,以查看您从针对这些目标的原子获得的汇编语言代码类型。

#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
  num.fetch_add(1, std::memory_order_relaxed);
}

int load_num() { return num; }            // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
  num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.



# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
    lock add        DWORD PTR num[rip], 1      #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
    ret
inc_seq_cst():
    lock add        DWORD PTR num[rip], 1
    ret
load_num():
    mov     eax, DWORD PTR num[rip]
    ret
store_num(int):
    mov     DWORD PTR num[rip], edi
    mfence                          ##### seq_cst stores need an mfence
    ret
store_num_release(int):
    mov     DWORD PTR num[rip], edi
    ret                             ##### Release and weaker doesn't.
store_num_relaxed(int):
    mov     DWORD PTR num[rip], edi
    ret

请注意在顺序一致性存储之后如何需要 MFENCE(完整屏障)。x86 通常是强排序的,但允许 StoreLoad 重新排序。拥有存储缓冲区对于流水线乱序
CPU 的良好性能至关重要。Jeff Preshing 的 Memory Reordering Caught in the
Act
展示了
使用 MFENCE 的后果,并使用真实代码显示了在真实硬件上发生的重新排序。


当前的编译器实际上并没有这样做(还),但不是因为不允许这样做。 C++
WG21/P0062R1:编译器何时应该优化原子?

讨论了许多程序员对编译器不会进行“令人惊讶的”优化的期望,以及该标准可以为程序员提供控制权。
N4455讨论了许多可以优化的例子,包括这个。它指出,内联和常量传播可以引入一些东西,比如fetch_or(0)它可能会变成一个load()(但仍然具有获取和释放语义),即使原始源没有任何明显冗余的原子操作。

编译器(还)不这样做的真正原因是:(1)没有人编写允许编译器安全地执行此操作的复杂代码(不会出错),以及(2)它可能违反最少原则惊喜。无锁代码一开始就很难正确编写。所以不要随意使用原子武器:它们并不便宜,也没有太多优化。但是,使用
避免冗余原子操作并不总是那么容易std::shared_ptr<T>,因为它没有非原子版本)。


回到num++; num-=2;编译状态: 允许num--编译器执行此操作,除非是. 如果可以重新排序,则 as-if
规则允许编译器在编译时决定它 总是 以这种方式发生。没有什么能保证观察者可以看到中间值(结果)。 num``volatile std::atomic<int> num++

即,如果在这些操作之间没有全局可见的排序与源的排序要求兼容(根据抽象机器的 C++ 规则,而不是目标体系结构),编译器可以发出单个lock dec dword [num]而不是lock inc dword [num]/ lock sub dword [num], 2

num++; num--不能消失,因为它仍然与查看 的其他线程具有 Synchronizes With
关系num,并且它既是获取加载又是释放存储,不允许重新排序该线程中的其他操作。对于 x86,这可能能够编译为 MFENCE,而不是lock add dword [num], 0(ie num += 0)。

正如PR0062中所讨论的,在编译时更积极地合并不相邻的原子操作可能是不好的(例如,进度计数器仅在结束时更新一次,而不是每次迭代),但它也可以在没有缺点的情况下提高性能(例如,跳过atomic
inc / dec of ref 在创建和销毁 a
的副本时计数shared_ptr,如果编译器可以证明另一个shared_ptr对象在临时的整个生命周期内都存在。)

num++; num--当一个线程立即解锁并重新锁定时,即使合并也会损害锁定实现的公平性。如果它从未真正在 asm
中释放,即使硬件仲裁机制也不会给另一个线程在此时获取锁的机会。


使用当前的 gcc6.2 和 clang3.9,即使在最明显可优化的情况下,您仍然可以获得单独的locked
操作。memory_order_relaxedGodbolt
编译器资源管理器
,因此您可以查看最新版本是否不同。)

void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
  num.fetch_add( 1, std::memory_order_relaxed);
  num.fetch_add(-1, std::memory_order_relaxed);
  num.fetch_add( 6, std::memory_order_relaxed);
  num.fetch_add(-5, std::memory_order_relaxed);
  //num.fetch_add(-1, std::memory_order_relaxed);
}

multiple_ops_relaxed(std::atomic<unsigned int>&):
    lock add        DWORD PTR [rdi], 1
    lock sub        DWORD PTR [rdi], 1
    lock add        DWORD PTR [rdi], 6
    lock sub        DWORD PTR [rdi], 5
    ret
2022-08-08