gpt4 book ai didi

c++ - num++ 可以是 'int num' 的原子吗?

转载 作者:行者123 更新时间:2023-12-01 16:23:15 29 4
gpt4 key购买 nike

一般来说,对于 int num , num++ (或 ++num ),作为读-修改-写操作,是 不是原子的 .但是我经常看到编译器,比如GCC ,为其生成以下代码( try here ):

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的开销。前缀。而且,正如在关于单处理器机器的部分中提到的公认答案,以及 this answer ,其评论中的对话和其他人解释, 可以 (虽然不是用 C 或 C++)。

最佳答案

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

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

Since num++ is one instruction (add dword [num], 1), can we conclude that num++ is atomic in this case?


内存目标指令(纯存储除外)是在多个内部步骤中发生的读-修改-写操作 .没有修改架构寄存器,但 CPU 在通过其 ALU 发送数据时必须在内部保存数据。 .即使是最简单的 CPU,实际的寄存器文件也只是数据存储的一小部分,锁存器将一个阶段的输出保存为另一阶段的输入,等等。
来自其他 CPU 的内存操作可以在加载和存储之间全局可见。即两个线程正在运行 add dword [num], 1在一个循环中会踩到对方的商店。 (请参阅 @Margaret's answer 以获得漂亮的图表)。从两个线程中的每一个增加 40k 之后,计数器在真正的多核 x86 硬件上可能只增加了 ~60k(不是 80k)。

“原子”来自希腊语,意思是不可分割的,意味着没有观察者可以将操作视为单独的步骤。在物理上/电气上同时对所有位同时发生只是实现加载或存储的一种方法,但这对于 ALU 操作来说甚至是不可能的。 我在对 Atomicity on x86 的回答中详细介绍了纯负载和纯存储,而这个答案侧重于读-修改-写。
lock prefix可以应用于许多读-修改-写(内存目标)指令,以使整个操作相对于系统中所有可能的观察者(其他内核和 DMA 设备,而不是连接到 CPU 引脚的示波器)具有原子性。这就是它存在的原因。 (另见 this Q&A)。
所以lock add dword [num], 1是原子的 .运行该指令的 CPU 内核会将缓存行保持在其私有(private) L1 缓存中的修改状态,从加载从缓存中读取数据,直到存储将其结果提交回缓存。根据 MESI cache coherency protocol 的规则,这可以防止系统中的任何其他缓存在从加载到存储的任何时间点拥有缓存行的拷贝。 (或分别由多核 AMD/Intel CPU 使用的 MOESI/MESIF 版本)。因此,其他核心的操作似乎发生在之前或之后,而不是期间。
没有 lock前缀,另一个核心可以获取缓存行的所有权并在我们的加载之后但在我们的存储之前修改它,这样其他存储将在我们的加载和存储之间变得全局可见。其他几个答案都弄错了,并声称没有 lock你会得到相同缓存行的冲突拷贝。这在具有一致缓存的系统中永远不会发生。
(如果 lock ed 指令在跨越两个缓存行的内存上运行,则需要做更多的工作来确保对象的两个部分的更改在传播到所有观察者时保持原子性,因此没有观察者可以看到撕裂。 CPU 可能必须锁定整个内存总线,直到数据到达内存为止。不要错位原子变量!)
请注意 lock前缀还将指令变成完整的内存屏障(如 MFENCE ),停止所有运行时重新排序,从而提供顺序一致性。 (参见 Jeff Preshing's excellent blog post 。他的其他帖子也都很出色,清楚地解释了很多关于 lock-free programming 的好东西,从 x86 和其他硬件细节到 C++ 规则。)

在单处理器机器上,或在单线程进程中 ,单 RMW指令实际上是原子的,没有 lock前缀。其他代码访问共享变量的唯一方法是让 CPU 进行上下文切换,这不能在指令中间发生。所以一个简单的 dec dword [num]可以在单线程程序及其信号处理程序之间同步,或者在单核机器上运行的多线程程序中同步。见 the second half of my answer on another question ,以及它下面的评论,我在那里更详细地解释了这一点。

回到 C++:
使用 num++ 完全是假的无需告诉编译器您需要将其编译为单个读-修改-写实现:
;; 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 will actually emit this,因为(奔腾)P5 的超标量管道没有像 P6 和更高版本的微体系结构那样将复杂的指令解码为多个简单的微操作。有关更多信息,请参阅 Agner Fog's instruction tables / microarchitecture guide,许多有用的链接(包括 Intel 的 x86 ISA 手册,请参阅 标签维基)以 PDF 格式免费提供))。

不要将目标内存模型 (x86) 与 C++ 内存模型混淆
Compile-time reordering允许 .使用 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 异步修改,但还有更多. (实际上有 similarities between volatile int to std::atomic with mo_relaxed 用于纯加载和纯存储操作,但不是用于 RMW。此外, volatile std::atomic<int> foo 不一定与 std::atomic<int> foo 相同,尽管当前的编译器不优化原子(例如2 个相同值的背靠背存储),因此 volatile atomic 不会更改代码生成。)
将非原子变量上的数据竞争定义为未定义行为使编译器仍然可以将加载和接收存储提升到循环之外,以及对多个线程可能具有引用的内存的许多其他优化。 (有关 UB 如何启用编译器优化的更多信息,请参阅 this LLVM blog。)

正如我提到的, x86 lock prefix是一个完整的内存屏障,所以使用 num.fetch_add(1, std::memory_order_relaxed);在 x86 上生成与 num++ 相同的代码(默认为顺序一致性),但在其他架构(如 ARM)上效率更高。即使在 x86 上,relaxed 也允许更多的编译时重新排序。
这就是 GCC 在 x86 上实际所做的,对于一些在 std::atomic 上运行的函数全局变量。
请参阅 Godbolt compiler explorer 上格式良好的源代码 + 汇编语言代码.您可以选择其他目标架构,包括 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 的后果,用真实的代码来展示在真实硬件上发生的重新排序。

回复:在评论中讨论@Richard Hodges 关于 的回答编译器合并 std::atomic num++; num-=2;操作合二为一 num--;说明 :
关于同一主题的单独问答: Why don't compilers merge redundant std::atomic writes? ,我的回答重申了我在下面写的很多内容。
当前的编译器实际上并没有这样做(还),但不是因为他们不允许这样做。 C++ WG21/P0062R1: When should compilers optimize atomics? 讨论了许多程序员对编译器不会进行“令人惊讶”的优化的期望,以及标准可以做什么来给予程序员控制权。 N4455讨论了许多可以优化的事情的例子,包括这个。它指出内联和常量传播可以引入诸如 fetch_or(0) 之类的东西。这可能会变成一个 load() (但仍然具有获取和释放语义),即使原始源没有任何明显冗余的原子操作。
编译器不这样做的真正原因(还)是:(1)没有人写出复杂的代码来允许编译器安全地这样做(永远不会出错),以及(2)它可能违反了 principle of least surprise .首先,无锁代码很难正确编写。因此,不要随意使用原子武器:它们并不便宜,也没有进行太多优化。使用 std::shared_ptr<T> 避免冗余原子操作并不总是那么容易,不过,因为没有它的非原子版本(尽管 one of the answers here 提供了一种简单的方法来为 gcc 定义 shared_ptr_unsynchronized<T>)。

返回 num++; num-=2;像编译一样编译 num-- :
编译器可以这样做,除非 numvolatile 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 中所述,在编译时更积极地合并非相邻的原子操作可能是不好的(例如,进度计数器只在最后更新一次而不是每次迭代),但它也可以帮助提高性能而没有缺点(例如跳过原子 inc/dec当 shared_ptr 的拷贝被创建和销毁时,ref 计数,如果编译器可以证明另一个 shared_ptr 对象存在于临时对象的整个生命周期。)
num++; num--当一个线程立即解锁和重新锁定时,合并可能会损害锁定实现的公平性。如果它从未在 asm 中真正释放过,那么即使是硬件仲裁机制也不会给另一个线程在此时获取锁的机会。

使用当前的 gcc6.2 和 clang3.9,您仍然可以获得单独的 lock ed 操作,即使使用 memory_order_relaxed在最明显可优化的情况下。 ( Godbolt compiler explorer 以便您可以查看最新版本是否不同。)
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

关于c++ - num++ 可以是 'int num' 的原子吗?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/39393850/

29 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com