这绝对是C ++定义为导致未定义行为的数据竞赛,即使一个编译器碰巧产生的代码在某些目标机器上达到了您希望的效果。您需要std::atomic
使用它来获得可靠的结果,但是memory_order_relaxed
如果您不关心重新排序,可以将其用于。请参阅下面的示例代码和使用asm输出fetch_add
。
但是首先,问题的汇编语言部分是:
由于num ++是一条指令(add dword [num], 1
),在这种情况下,我们可以得出num ++是原子的结论吗?
内存目标指令(纯存储除外)是在多个内部步骤中进行的读取,修改,写入操作。无需修改体系结构寄存器,但CPU在通过ALU发送数据时必须在内部保留数据。即使是最简单的CPU,实际的寄存器文件也只是数据存储区的一小部分,其锁存器将一级的输出保存为另一级的输入,依此类推。
其他CPU的内存操作可以在加载和存储之间全局可见。即,两个add dword [num], 1
循环运行的线程会互相踩踏。(请参见@Margaret的答案以获取漂亮的图表)。从两个线程中的每个线程递增40k之后,在真正的多核x86硬件上,计数器可能只增加了约60k(而不是80k)。
“原子”,来自希腊语,意思是不可分割的,意味着没有观察者可以看到操作作为单独的步骤。同时对所有位进行物理/电气瞬时处理只是实现加载或存储操作的一种方法,但对于ALU操作而言,这甚至是不可能的。在x86上对Atomicity的回答中,我对纯负载和纯存储进行了更详细的介绍,而该答案的重点是读-修改-写。
的lock
前缀可以被应用于许多读-修改-写(存储目的地)的指令,以使整个操作原子相对于系统中的所有可能的观察者(其它内核和DMA设备,而不是一个示波器挂接到CPU引脚)。这就是为什么它存在。(另请参阅本问答)。
原子lock add dword [num], 1
也是如此。从加载从缓存读取数据到存储将结果提交回缓存之前,运行该指令的CPU内核会将缓存行保持在其私有L1缓存中的修改状态。根据MESI缓存一致性协议(或多核AMD / MS使用的MOESI / MESIF版本)的规则,这可以防止系统中的任何其他缓存在加载到存储的任何时候都拥有缓存行的副本。分别为Intel CPU)。因此,其他内核的操作似乎发生在此之前或之后,而不是在此期间。
如果没有lock
前缀,则另一个核心可以拥有缓存行的所有权,并在加载之后但在存储之前对其进行修改,以便其他存储在加载和存储之间成为全局可见的。其他几个答案也弄错了,并声称如果没有,lock
您将获得同一高速缓存行的冲突副本。在具有相干缓存的系统中永远不会发生这种情况。
(如果lock
ed指令在跨越两条高速缓存行的内存上进行操作,则需要花费更多的工作来确保对象的两个部分的更改在传播给所有观察者时都保持原子性,因此观察者看不到任何裂痕。CPU可能必须锁定整个内存总线,直到数据到达内存为止。不要使您的原子变量对齐!)
请注意,lock
前缀还将指令转换为完整的内存屏障(如MFENCE),从而停止了所有运行时重新排序,从而提供了顺序一致性。(请参阅Jeff Preshing的出色博客文章。他的其他文章也都很出色,并且清楚地解释了很多有关无锁编程的好东西,从x86和其他硬件详细信息到C ++规则。)
在单处理器机器上或在单线程进程中,单个RMW指令实际上是原子的,没有lock
前缀。其他代码访问共享变量的唯一方法是让CPU进行上下文切换,这不会在指令中间发生。因此,普通dec dword [num]
用户可以在单线程程序及其信号处理程序之间或在单核计算机上运行的多线程程序之间进行同步。请参阅我对另一个问题的答案的下半部分,以及该问题下的评论,在此处我将详细解释。
返回C ++:
在num++
不告知编译器您需要将其编译为单个read-modify-write实现的情况下使用完全是伪造的:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
如果您使用num
稍后的值,则很有可能会发生这种情况:增量后,编译器会将其保留在寄存器中。因此,即使您自己检查num++
编译方式,更改周围的代码也会对其产生影响。
(如果以后不再需要该值,inc dword [num]
则是首选;现代x86 CPU将运行内存目标RMW指令至少与使用三个单独的指令一样有效。有趣的是:gcc -O3 -m32 -mtune=i586
实际上会发出此指令,因为(Pentium)P5的超标量管道没有不要像P6和更高版本的微体系结构那样,将复杂的指令解码为多个简单的微操作。有关更多信息,请参见Agner Fog的指令表/微体系结构指南,以及x86 标记Wiki以获得许多有用的链接(包括Intel的x86 ISA手册,这些手册可以PDF的形式免费获得)。
不要将目标内存模型(x86)与C ++内存模型混淆
允许在编译时重新排序。使用std :: atomic获得的另一部分是对编译时重新排序的控制,以确保num++
仅在执行某些其他操作之后您才能全局可见。
经典示例:将一些数据存储到缓冲区中以供另一个线程查看,然后设置一个标志。即使x86确实免费获取加载/发布存储,您仍然必须告诉编译器不要使用来重新排序flag.store(1, std::memory_order_release);
。
您可能期望此代码将与其他线程同步:
// flag is just a plain int global, not std::atomic<int>.
flag--; // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo); // doesn't look at flag, and the compilers 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 std::atomic<int> foo
不是与std::atomic<int> foo
,与@Richard Hodges讨论的相同)。
将非原子变量上的数据竞争定义为“未定义行为”是使编译器仍然可以将加载和接收存储提升到循环之外,以及对多个线程可能引用的其他许多内存优化。(有关UB如何实现编译器优化的更多信息,请参见LLVM博客。)
如前所述,x86 lock
前缀是一个完整的内存屏障,因此使用会num.fetch_add(1, std::memory_order_relaxed);
在x86上生成与相同的代码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; } // 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)在《法案》中涉及的“ 内存重排序”显示了不使用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>
但是,由于没有非原子版本,因此使用来避免多余的原子操作并不总是那么容易(尽管这里的答案之一提供了一种shared_ptr_unsynchronized<T>
为gcc 定义a的简便方法)。
回到num++; num-=2;
编译状态num--
:除非是,否则允许编译器执行此num
操作volatile std::atomic<int>
。如果可以重新排序,则按条件规则允许编译器在编译时确定它总是以这种方式发生。不能保证观察者可以看到中间值(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
临时对象的整个生命周期中存在另一个对象,则ref的原子inc / dec计数在创建和销毁a的副本时进行。
num++; num--
当一个线程立即解锁并重新锁定时,即使合并也会损害锁定实现的公平性。如果它实际上从未在asm中发布,那么即使是硬件仲裁机制也不会给另一个线程提供机会在这一点上获得锁定。
使用当前的gcc6.2和clang3.9,lock
即使memory_order_relaxed
在最明显的情况下,您仍然可以得到单独的ed操作。(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);
//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
add
是原子的?