当前位置: 首页 > 知识库问答 >
问题:

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

李跃
2023-03-14

通常,对于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++在本例中是原子的呢?

更新

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

共有1个答案

宰父冠玉
2023-03-14

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

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

既然num++是一条指令(添加dword[num],1),那么我们是否可以断定num++在这种情况下是原子的呢?

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

所以锁添加dword[num],1是原子的。运行该指令的CPU内核将在其专用L1缓存中保持缓存线的修改状态,从加载从缓存中读取数据到存储将其结果提交回缓存。根据MESI高速缓存一致性协议(或分别由多核AMD/Intel CPU使用的MOESI/MESIF版本)的规则,这防止系统中的任何其他高速缓存在从加载到存储的任何点具有高速缓存行的副本。因此,其他核心的操作似乎是在之前或之后发生的,而不是在期间发生的。

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

(如果lockED指令对跨越两个缓存行的内存进行操作,则需要更多的工作来确保对象的两个部分的更改在传播到所有观察者时保持原子性,这样观察者就不会看到撕裂。CPU可能必须锁定整个内存总线,直到数据到达内存。不要对齐原子变量!)

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

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

使用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实际上会发出该指令,因为(奔腾)P5的超标量管道不像P6和更高版本的微架构那样将复杂指令解码为多个简单的微操作。有关更多信息,请参阅Agner Fog的指令表/微架构指南,有关许多有用链接,请参阅x86标签wiki(包括Intel的x86 ISA手册,可免费获得PDF格式))。

允许编译时重新排序。使用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++;

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

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

请参阅Godbolt编译器资源管理器上格式化良好的源代码+汇编语言代码。您可以选择其他目标体系结构,包括ARM、MIPS和PowerPC,以查看您从atomics获得的用于这些目标的哪种汇编语言代码。

#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::AtomicNUM++的回答的评论讨论;num-=2;操作到一个num--;指令:

关于这个问题的另一个问答:为什么编译器不合并冗余的STD::Atomic写?,我的回答重述了我在下面写的很多东西。

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

回到num++;num-=2;像编译一样编译num--:编译器可以这样做,除非numvolatile std::Atomic 。如果重新排序是可能的,则as-if规则允许编译器在编译时决定它总是以这种方式发生。不能保证观察者能够看到中间值(num++结果)。

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

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

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

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

使用当前的GCC6.2和CLANG3.9,即使在最明显可优化的情况下使用memory_order_replaced,仍然可以获得单独的locked操作。(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
 类似资料:
  • 我知道下一个场景:(奇怪的格式,我知道) 如果线程#1和线程#2在完全相同的时间输入,这将发生: > 两者都将执行" CMPXCHG指令同时对两个线程生效: 3.1锁定前缀本机使用 3.2线程#1或#2首先到达,赢得比赛。 3.3获胜线程比较(是aBoolean==true?)这将返回"true",因此一个布尔值将被设置为"false"。 3.4 aBoolean现在为false。 3.5线程丢失

  • 假设以下两种计数器实现: 乍一看,原子应该更快、更具可扩展性。我相信他们是的。但是它们始终比同步块快吗?或者当该规则被打破时,存在某些情况(例如SMP/单CPU机器、不同的CPU ISA、操作系统等)?

  • Solr4提供了对索引中现有文档进行原子(部分)更新的功能。即。可以匹配文档ID并替换一个字段的内容,或者向多值字段添加更多条目:http://wiki.apache.org/solr/atomicupdates 原子更新可以从DataImportHandler(DIH)完成吗?

  • 正如在C 11中所知,有6个内存顺序,在关于std::memory\u order\u acquire的文档中: http://en.cppreference.com/w/cpp/atomic/memory_order memory\u order\u acquire内存\u顺序\u获取 具有此内存顺序的加载操作对受影响的内存位置执行获取操作:在此加载之前,当前线程中的内存访问不能重新排序。这可以

  • 问题内容: 我无法通过实验进行检查,也无法从手册页中收集到它。 说我有两个过程,一个是将文件1从目录1移动(重命名)到目录2。假设正在运行的另一个进程同时将directory1和directory2的内容复制到另一个位置。复制是否可能以这种方式发生,即目录1和目录2都将显示文件1-即,目录1在移动之前被复制,目录2在移动之后被第一个进程复制。 基本上,rename()是原子系统调用吗? 谢谢 问题

  • 问题内容: 我有几个在单个WebLogic集群中运行的J2EE应用程序实例。 在某些时候,这些应用程序会进行合并以将记录插入或更新到后端Oracle数据库中。MERGE检查是否存在具有指定主键的行。如果在那里,请更新。如果没有,请插入。 现在,假设有两个应用程序实例要插入或更新主键= 100的行。假设该行不存在。在合并的“检查”阶段,他们都看到行不在此处,因此他们都试图插入。然后,我得到了唯一的键