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

新手上路,请多包涵

一般来说,对于 int numnum++ (或 ++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

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

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

更新

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

原文由 Leo Heinsaar 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 709
2 个回答

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


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

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

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

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


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

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

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

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

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

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


在单处理器机器或单线程进程中,单个 RMW 指令实际上 原子的,没有 lock 前缀。其他代码访问共享变量的唯一方法是 CPU 进行上下文切换,这不能在指令中间发生。因此,一个普通的 dec dword [num] 可以在单线程程序及其信号处理程序之间同步,或者在单核机器上运行的多线程程序中同步。请参阅 我对另一个问题的回答的后半部分,以及它下面的评论,我在其中更详细地解释了这一点。


回到 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 实际上会发出这个,因为 (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 )。然后它可以完全优化修改,因为 flag 甚至不是 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 个相同值的背靠背存储),因此 volatile atomic 不会更改代码生成。)

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


正如我所提到的, x86 lock 前缀 是一个完整的内存屏障,因此使用 num.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 的后果,并使用真实代码显示了在真实硬件上发生的重新排序。


回复:@Richard Hodges 关于 编译器将 std::atomic num++; num-=2; 操作合并为一个 num--; 指令 的评论中的讨论:

关于同一主题的单独问答: 为什么编译器不合并冗余的 std::atomic 写入? ,我的回答重申了我在下面写的很多内容。

当前的编译器实际上并没有这样做(还),但不是因为不允许这样做。 C++ WG21/P0062R1:编译器何时应该优化原子? 讨论了许多程序员对编译器不会进行“令人惊讶的”优化的期望,以及该标准可以为程序员提供控制权。 N4455 讨论了许多可以优化的例子,包括这个。它指出,内联和持续传播可以引入诸如 fetch_or(0) 类的东西,它可能会变成 load() (但仍然具有获取和释放语义),即使原始source 没有任何明显冗余的原子操作。

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


Getting back to num++; num-=2; compiling as if it were num-- : Compilers are allowed to do this, unless num is 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 中所讨论的,在编译时更积极地合并不相邻的原子操作可能是不好的(例如,进度计数器仅在结束时更新一次,而不是每次迭代),但它也可以在没有缺点的情况下提高性能(例如,跳过atomic inc / dec of ref 在创建和销毁 shared_ptr 的副本时计数,如果编译器可以证明另一个 shared_ptr 对象在临时的整个生命周期内都存在。)

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


使用当前的 gcc6.2 和 clang3.9,即使在最明显可优化的情况下使用 memory_order_relaxed ,您仍然可以获得单独的 lock 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

原文由 Peter Cordes 发布,翻译遵循 CC BY-SA 4.0 许可协议

没有太多复杂性,像 add DWORD PTR [rbp-4], 1 这样的指令非常符合 CISC 风格。

它执行三个操作:从内存中加载操作数,递增它,将操作数存储回内存。

在这些操作期间,CPU 两次获取和释放总线,中间任何其他代理也可以获取它,这违反了原子性。

 AGENT 1          AGENT 2

load X
inc C
                 load X
                 inc C
                 store X
store X

X 只增加一次。

原文由 Margaret Bloom 发布,翻译遵循 CC BY-SA 3.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题