这绝对是C++所定义的数据竞争引发未定义行为的情况,即使某个编译器在某个目标机器上产生了你所期望的结果。如果你希望得到可靠的结果,你需要使用
std::atomic
,但如果你不关心重排序,你可以使用
memory_order_relaxed
。请参考下面的示例代码和使用
fetch_add
的汇编输出。
非
atomic
变量上的数据竞争导致了C++编译器仍然可以对普通的
int
进行积极的优化,为非共享的变量(以及单线程程序)生成快速的代码。
但首先,问题的汇编语言部分:
由于num++是一条指令(add dword [num], 1),我们能否得出结论,在这种情况下,num++是原子操作?
内存目标指令(除了纯存储指令)是多步骤的读取-修改-写入操作。虽然不会修改架构寄存器,但CPU在将数据通过其
ALU时必须在内部保持数据。实际的寄存器文件只是即使在最简单的CPU中也只是数据存储的一小部分,其中锁存器将一个阶段的输出保持为另一个阶段的输入,依此类推。
在加载和存储之间,来自其他CPU的内存操作可能会在全局范围内可见。也就是说,两个线程在循环中运行
add dword [num], 1
会相互干扰对方的存储。在两个线程中的每个线程增加了40k次后,实际的多核x86硬件上计数器可能只增加了大约60k次(而不是80k次)。详细信息请参见
@Margaret's answer中的漂亮图表。
“原子性”一词源自希腊语,意为不可分割,意味着没有观察者能够将操作看作是分步进行的。对于所有位同时发生的物理/电气瞬间,这只是实现加载或存储的一种方式,但对于ALU操作来说,这甚至都不可能。
在我的回答中,我详细讨论了纯加载和纯存储的问题(参见
《x86上的加载和存储的原子性》),而本回答则侧重于读取-修改-写入。
lock
前缀可以应用于许多读取-修改-写入(内存目标)指令,以使整个操作对系统中的所有可能观察者(其他核心和DMA设备,而不是连接到CPU引脚的示波器)具有原子性。这就是它存在的原因(另请参阅
此问答)。
所以lock add dword [num], 1
是原子操作。执行该指令的CPU核心会将缓存行固定在其私有L1缓存中的修改状态,从加载从缓存中读取数据到存储将结果提交回缓存的过程中。根据MESI缓存一致性协议的规则(或者多核AMD/Intel CPU分别使用的MOESI/MESIF版本),这可以防止系统中的任何其他缓存在加载到存储的任何时刻都具有缓存行的副本。因此,其他核心的操作似乎发生在加载和存储之前或之后,而不是在其间。
如果没有lock
前缀,另一个核心可能会接管缓存行并在我们的加载之后但在我们的存储之前对其进行修改,这样其他存储就会在我们的加载和存储之间变得全局可见。有几个其他答案对此理解错误,并声称没有lock
会导致相同缓存行的冲突副本。在具有一致性缓存的系统中,这是不可能发生的。
(如果一个锁定的指令操作跨越两个缓存行的内存,就需要更多的工作来确保对对象的两个部分的更改在传播到所有观察者时保持原子性,以便没有观察者能够看到撕裂。CPU可能需要锁定整个内存总线,直到数据写入内存。不要使您的原子变量错位!)
请注意,锁定前缀还将指令转换为完全的内存屏障(类似于MFENCE),停止所有运行时重新排序,从而提供顺序一致性。(请参阅Jeff Preshing的出色博客文章。他的其他文章也都很出色,并清楚地解释了关于无锁编程的很多好东西,从x86和其他硬件细节到C++规则。)
在单处理器机器上,或者在单线程进程中,一个单一的RMW指令实际上是原子的,不需要lock前缀。其他代码访问共享变量的唯一方式是CPU进行上下文切换,这在指令执行过程中是不可能发生的。因此,一个简单的dec dword [num]指令可以在单线程程序及其信号处理程序之间进行同步,或者在单核机器上运行的多线程程序中进行同步。请参阅我在另一个问题中的回答的后半部分以及其下的评论,我在其中对此进行了更详细的解释。
回到C++:
在没有告诉编译器你需要将其编译为单个读取-修改-写入实现的情况下使用`num++`是完全错误的。
mov eax, [num]
inc eax
mov [num], eax
这很可能发生,如果你稍后使用
num
的值:编译器会在递增后将其保持在寄存器中。因此,即使你检查
num++
单独编译的方式,改变周围的代码也可能会影响它。
(如果后续不需要该值,最好使用
inc dword [num]
;现代x86 CPU将以至少与使用三个单独指令相同的效率运行内存目标的RMW指令。有趣的事实是:
gcc -O3 -m32 -mtune=i586
实际上会生成这个指令,因为(奔腾)P5的超标量流水线不能将复杂指令解码为多个简单的微操作,而P6及后续微架构可以。请参阅
Agner Fog的指令表/微架构指南以获取更多信息,并参阅
x86标签维基以获取许多有用的链接(包括英特尔的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
)。然后它可以完全优化掉这个修改,因为
flag
甚至不是
volatile
。
(不,C++的
volatile
不能替代std::atomic。std::atomic确实使编译器假设内存中的值可以异步修改,类似于
volatile
,但其中还有更多内容。(实际上,在纯加载和纯存储操作方面,volatile int与std::atomic之间存在相似之处,但对于RMW操作则不然)。此外,
volatile std::atomic<int> foo
不一定等同于
std::atomic<int> foo
,尽管当前的编译器不会优化原子操作(例如连续两次存储相同值),所以volatile atomic不会改变代码生成。)
定义对非原子变量的数据竞争为未定义行为是让编译器仍然可以将加载和存储操作提升到循环外,并进行许多其他针对多个线程可能引用的内存的优化的原因。 (有关UB如何实现编译器优化的更多信息,请参见
此LLVM博客。)
正如我之前提到的,
x86 lock
前缀是一个完整的内存屏障,所以在x86上使用
num.fetch_add(1, std::memory_order_relaxed);
生成的代码与
num++
相同(默认是顺序一致性),但在其他架构(如ARM)上可能更高效。即使在x86上,松散的内存序也允许更多的编译时重排序。
这是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; }
void store_num(int val){ num = val; }
void store_num_release(int val){
num.store(val, std::memory_order_release);
}
inc_relaxed():
lock add DWORD PTR num[rip], 1
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
ret
store_num_release(int):
mov DWORD PTR num[rip], edi
ret
store_num_relaxed(int):
mov DWORD PTR num[rip], edi
ret
注意在顺序一致性存储之后需要使用MFENCE(完全屏障)。x86通常是强有序的,但允许存储-加载重排序。在流水线乱序CPU上,拥有存储缓冲区对于性能至关重要。Jeff Preshing的《
内存重排序的真实表现》展示了不使用MFENCE的后果,并展示了在真实硬件上发生的重排序现象。
关于@Richard Hodges在评论中讨论的有关编译器将std::atomic的"num++; num-=2;"操作合并为"num--;"指令的问题:
关于同一主题的另一个问答:
为什么编译器不合并冗余的std::atomic写操作?,我的回答重新阐述了下面我所写的很多内容。
当前的编译器实际上还没有做到这一点(尚未),但并不是因为它们没有被允许这样做。《C++ WG21/P0062R1:编译器何时应该优化原子操作?》讨论了许多程序员对编译器不会进行“令人惊讶”优化的期望,以及标准可以采取的措施来给程序员提供控制权。《N4455》讨论了许多可以进行优化的示例,包括这个例子。它指出内联和常量传播可以引入诸如
fetch_or(0)
这样的内容,即使原始源代码中没有明显多余的原子操作,也可以转化为只是一个
load()
(但仍具有获取和释放语义)。
编译器尚未实现这个功能的真正原因是:(1)还没有人编写复杂的代码,使得编译器能够安全地执行这个功能(确保不会出错);(2)这可能违反了“最小惊讶原则”。编写无锁代码本身就很困难,所以在使用原子操作时不要随意轻率,因为它们既不便宜也没有太多优化效果。然而,对于
std::shared_ptr<T>
,要避免冗余的原子操作并不总是容易,因为它没有非原子版本(尽管
这里的一个答案提供了一种在gcc中定义
shared_ptr_unsynchronized<T>
的简单方法)。
回到
num++; num-=2;
编译时,就好像它是
num--
一样:
编译器是允许这样做的,除非
num
是
volatile std::atomic<int>
类型。如果存在重新排序的可能性,as-if规则允许编译器在编译时决定它总是以这种方式发生。没有任何保证观察者能够看到中间值(即
num++
的结果)。
也就是说,如果在这些操作之间没有任何东西在全局上可见的顺序与源代码的顺序要求相兼容(根据C++对抽象机器的规则,而不是目标架构),编译器可以发出一个
lock dec dword [num]
而不是
lock inc dword [num]
/
lock sub dword [num], 2
。
num++; num--
不能消失,因为它与查看num
的其他线程之间仍然存在同步关系,并且它既是获取加载又是释放存储,这禁止了在此线程中重新排序其他操作。对于x86架构,这可能会编译为MFENCE,而不是lock add dword [num], 0
(即num += 0
)。
如PR0062中所讨论的,编译时更积极地合并非相邻的原子操作可能是不好的(例如,进度计数器只在最后一次更新而不是每次迭代时更新),但它也可以在没有副作用的情况下提高性能(例如,在创建和销毁shared_ptr
的临时对象时,跳过原子增加/减少引用计数,如果编译器可以证明另一个shared_ptr
对象在整个临时对象的生命周期中存在。)
即使合并
num++; num--
也可能损害锁实现的公平性,当一个线程解锁并立即重新锁定时。如果在汇编中实际上从未释放,即使硬件仲裁机制也不会给另一个线程在此时抢占锁的机会。
在当前的gcc6.2和clang3.9版本中,即使在最明显可以优化的情况下,使用memory_order_relaxed
仍然会得到单独的lock
操作。(您可以使用Godbolt编译器探索器查看最新版本是否有所不同。)
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);
}
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
add
是原子操作的? - Slavastd :: atomic<int>
来实现平台无关性并表达您的意图。 - NathanOliveradd
指令期间,另一个核心可能会从这个核心的缓存中窃取该内存地址并进行修改。在x86 CPU上,如果需要在操作期间将地址锁定在缓存中,则add
指令需要使用lock
前缀。 - David Schwartz