在特定情况下,递增一个整数是否有效地原子化?

186
通常情况下,对于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

自从第5行,对应于num++的指令是一条指令,我们能否得出num++在这种情况下是原子的的结论?

如果是这样的话,这是否意味着所生成的num++可以在并发(多线程)场景中使用而不会出现数据竞争的危险(即我们不需要将其变为std::atomic<int>并附加相关的成本,因为它已经是原子的)?

更新

注意,问题不是增量是否是原子的(它不是,并且这是问题的开头和现在的状态)。相反,问题是在某些情况下它是否可以是原子的,即在某些情况下是否可以利用单指令的特性来避免使用“lock”前缀的开销。正如被接受的答案在关于单处理器机器的部分以及this answer中提到的,以及在其评论和其他解释中所提到的,它是可以的(尽管不能用C或C++实现)。

71
谁告诉你 add 是原子操作的? - Slava
7
鉴于原子操作的一个特点是在优化期间防止特定类型的重新排序,因此无论实际操作是否具有原子性,都不会发生这种重新排序。 - jaggedSpire
21
我还想指出,如果这在您的平台上是原子性的,那么并不保证它在另一个平台上也是。通过使用 std :: atomic<int> 来实现平台无关性并表达您的意图。 - NathanOliver
9
在执行add指令期间,另一个核心可能会从这个核心的缓存中窃取该内存地址并进行修改。在x86 CPU上,如果需要在操作期间将地址锁定在缓存中,则add指令需要使用lock前缀。 - David Schwartz
25
任何操作都有可能是“原子”的。只需幸运地避免执行任何会显示它不是原子的操作即可。原子性只有作为一种保证时才有价值。考虑到你正在观察汇编代码,问题在于该特定体系结构是否提供了这种保证,以及编译器是否提供了保证,即选择它作为汇编级别实现的保证。 - Cort Ammon
显示剩余5条评论
13个回答

249
这绝对是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++`是完全错误的。
;; 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 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; }            // 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通常是强有序的,但允许存储-加载重排序。在流水线乱序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--一样: 编译器是允许这样做的,除非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中所讨论的,编译时更积极地合并非相邻的原子操作可能是不好的(例如,进度计数器只在最后一次更新而不是每次迭代时更新),但它也可以在没有副作用的情况下提高性能(例如,在创建和销毁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);
  //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
“[使用单独的指令] 曾经更有效率...但是现代x86 CPU再次以至少与RMW操作同样高效地处理它” - 在更新的值稍后在同一函数中使用且编译器有一个可用的自由寄存器来存储它(当然,变量没有标记为易失性)的情况下,它仍然更有效率。这意味着编译器生成单个指令还是多个指令取决于函数中的其他代码,而不仅仅是问题中的单个行。 - Periata Breatta
4
如果你有任何想要编辑的地方,请随意修改。但我不想将它变成共同创作模式。这仍然是我的工作(和我的混乱:P)。等我打完极限飞盘比赛后,我会把它整理一下 :) - Peter Cordes
1
如果不是社区维基,那么也许可以在相应的标签维基上放一个链接(包括x86和atomic标签?)。这比在S.O.上进行通用搜索更值得额外的链接。如果我更清楚它应该放在哪里,我会这样做。我将进一步挖掘标签维基链接的注意事项。 - David C. Rankin
1
一如既往 - 很棒的回答!很好地区分了一致性和原子性(而其他一些人则搞错了)。 - Leeor
1
@МаксФедотов:好的,有趣,感谢找到了确切的措辞。我猜那可能是一种硬件设计,但我不确定真正的CPU是否是这样设计的。对我来说,这听起来像是OoO早期执行负载的替代心理模型。我不能确定;如果还没有重复的问题,这可能会成为一个有趣的SO问题。如果您想提出这样的问题,请随意引用或转述我的任何评论。我从未见过CPU供应商宣传新版本具有更大的“无效队列”以获得更好的内存并行性,只有负载缓冲区。 - Peter Cordes
显示剩余33条评论

51

没有太多复杂的问题,像 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只被增加了一次。


7
为了实现这一点,每个存储芯片都需要拥有自己的算术逻辑单元(ALU)。这实际上要求每个存储芯片本身都是一个处理器。 - Richard Hodges
7
@LeoHeinsaar说,内存目的地指令是读取-修改-写入操作。不会修改任何架构寄存器,但CPU在将数据通过它的ALU发送时必须在内部保存数据。实际的寄存器文件只是最简单的CPU中数据存储的一小部分,其中锁存器保持一个阶段的输出作为另一个阶段的输入等等。 - Peter Cordes
@PeterCordes 您的评论正是我在寻找的答案。Margaret的回答让我怀疑里面一定有类似的东西。 - Leo Heinsaar
将那个评论改写为完整的答案,包括回答问题中涉及的C++部分。 - Peter Cordes
1
@PeterCordes 谢谢,非常详细并且全面。显然这是一个数据竞争,因此根据C++标准是未定义行为。我只是好奇在生成的代码与我所发布的代码一样的情况下,是否可以假设它可能是原子操作等等。我还检查了至少英特尔开发者手册中非常明确地将“原子性”定义为“内存操作”而不是指令不可分割性,正如我所认为的:“‘锁定’操作对于所有其他内存操作和所有外部可见事件来说都是原子的。” - Leo Heinsaar

42

...现在让我们启用优化:

f():
        rep ret

好的,让我们给它一个机会:

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

结果:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

另一个观察线程(即使忽略缓存同步延迟)也没有机会观察到单个更改。

相比之下:

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

结果如下:

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

现在,每个修改都是:

  1. 可以在另一个线程中观察到
  2. 对其他线程中发生的类似修改保持尊重。

原子性不仅限于指令级别,它涉及从处理器,通过缓存,到内存再返回的整个流水线。

进一步信息

关于对 std::atomic 更新优化的影响。

C++ 标准有“好像”规则,允许编译器重新排序代码,甚至重写代码,只要结果具有与执行源代码相同的可观察效果(包括副作用)。

as-if 规则非常保守,特别是涉及原子操作时。

考虑:

void incdec(int& num) {
    ++num;
    --num;
}

由于没有互斥锁、原子性或任何其他影响线程间排序的构造,我认为编译器可以将此函数重写为NOP,例如:

void incdec(int&) {
    // nada
}

这是因为在 C++ 内存模型中,不存在另一个线程观察增量的可能性。当然,如果 numvolatile 的话,情况会有所不同(可能会影响硬件行为)。但在这种情况下,此函数将是修改该内存的唯一函数(否则程序将是非法的)。

然而,这是一个不同的问题:

void incdec(std::atomic<int>& num) {
    ++num;
    --num;
}

num 是一个原子类型。对它的更改必须能够被观察到正在监视它的其他线程。这些线程自己进行的更改(例如在增加和减少之间将值设置为100)会对 num 的最终值产生非常深远的影响。

以下是演示:

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });
        
        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

样例输出:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

5
这没有解释add dword [rdi], 1在没有lock前缀时不是原子操作。读取是原子的,存储是原子的,但是没有任何阻止另一个线程在读取和存储之间修改数据。因此,存储可能会覆盖其他线程进行的修改。请参见https://jfdube.wordpress.com/2011/11/30/understanding-atomic-operations/。此外,[Jeff Preshing的无锁文章非常好](http://preshing.com/20120612/an-introduction-to-lock-free-programming/),并且他在那篇简介文章中提到了基本的RMW问题。 - Peter Cordes
2
另一个观察线程(即使忽略缓存同步延迟)也没有机会观察到单个更改 - 这实际上是个问题吗?即使使用std::atomic<int>&,我认为编译器也可以将所有这些操作合并为一个。 - user2357112
4
这里的实际情况是,没有人在gcc中实现这种优化,因为它几乎没有用处,而且可能会比有帮助更加危险。避免出现惊奇原则。也许有人期望临时状态有时可见,并且对统计概率感到满意。或者他们正在使用硬件监视点来在修改时中断。无锁代码需要仔细设计,以便不会有任何需要优化的地方。最好查找并打印警告,提醒程序员他们的代码可能不是他们想要的! - Peter Cordes
3
这可能是编译器没有实现这个(最小惊讶原则等)的原因。在实际的硬件上观察是可能的。然而,C++内存排序规则并没有关于一个线程的加载能否与其他线程的操作“平均”混合在C++抽象机中的任何保证。我仍然认为这是合法的,但对程序员不友好。 - Peter Cordes
6
为了达到最终确定性,我在标准讨论邮件列表上提出了这个问题。这个问题引出了两篇论文,它们似乎都支持Peter的观点,并解决了我对这种优化的担忧:http://wg21.link/p0062 和 http://wg21.link/n4455 。感谢Andy向我指出这些内容。 - Richard Hodges
显示剩余19条评论

10

加法指令不是原子性的。它引用内存,两个处理器核心可能具有该内存的不同本地缓存。

如果我没记错,加法指令的原子变体称为lock xadd


3
lock xadd 实现了 C++ 的 std::atomic fetch_add,并返回旧值。 如果您不需要这个功能,编译器将使用带有 lock 前缀的常规内存目标指令。 可以使用 lock addlock inc - Peter Cordes
1
在没有缓存的SMP机器上,“add [mem],1”仍然不是原子操作,请参考我对其他答案的评论。 - Peter Cordes
在我的回答中,详细说明了它为什么不是原子性的。此外,在这个相关问题的结尾处也有我的回答。[https://dev59.com/jVoT5IYBdhLWcg3wjwB-] - Peter Cordes
1
同样地,更根本的是,不,两个内核不能在相同的内存中有不同的缓存缓存是一致的。请不要传播关于CPU如何工作的错误信息。另请参见程序员认为关于CPU缓存的谬论(Java的volatile类似于C++的具有memory_order_seq_cst的std::atomic<>)。请参见Margaret的答案,了解两个内核读取相同值会导致问题的原因。 - Peter Cordes

10
自从第5行开始,对应于num++的是一条指令,我们可以得出结论说在这种情况下num++是原子的吗?
基于"逆向工程"生成的汇编代码来得出结论是很危险的。例如,你似乎已经禁用了优化选项来编译代码,否则编译器将会丢弃该变量或者直接加载1到该变量中而不调用operator++。因为生成的汇编代码可能会根据优化标志、目标CPU等显著改变,所以你的结论有点不可靠。
另外,你认为一个汇编指令就意味着操作是原子性的这个想法也是错误的。即使在x86架构上,这个add操作在多CPU系统上也不是原子性的。

9
在单核x86机器上,add指令通常在CPU上的其他代码方面是原子性的。中断无法将单条指令拆分成两半。
为了保持单个内核中指令顺序执行的幻象,需要采用乱序执行。因此,在同一CPU上运行的任何指令都会在add之前或之后完全发生。
现代x86系统是多核的,因此单处理器特殊情况不适用。
如果目标是小型嵌入式PC,并且没有将代码移动到其他设备的计划,则可以利用“add”指令的原子性。另一方面,操作本质上是原子性的平台越来越少。
(然而,如果您使用C ++编写,则无济于事。编译器没有要求num ++编译成一个内存目标加或XADD,而没有lock 前缀的选项。编译器可能会选择将num加载到寄存器中,并使用单独的指令存储增量结果,如果您使用该结果,则可能会这样做。)
注1:即使在原始8086上,lock前缀也存在,因为I/O设备与CPU并发操作;在单核系统上,如果设备也可以修改它,驱动程序需要lock add以原子方式递增设备内存中的值,或者与DMA访问相关。

它甚至不是原子性的:另一个线程可以同时更新相同的变量,只有一个更新被接受。 - fuz
1
考虑一个多核系统。当然,在一个核心内,指令是原子的,但它对整个系统来说并不是原子的。 - fuz
1
@FUZxxl:我的回答中第四个和第五个单词是什么? - supercat
1
@supercat,你的回答非常误导人,因为它只考虑了现今罕见的单核情况,并给OP带来了虚假的安全感。这就是我评论提醒要同时考虑多核情况的原因。 - fuz
2
@FUZxxl:我进行了编辑,以消除那些没有注意到这不是关于普通现代多核CPU的读者可能会产生的困惑(并且更具体地说明了一些supercat不确定的事情)。顺便说一句,除了关于原子读改写在某些平台上“免费”的最后一句话之外,这个回答中的所有内容都已经在我的回答中了。 - Peter Cordes
显示剩余15条评论

9
即使您的编译器总是将其作为原子操作发出,从任何其他线程并发访问num都会构成数据竞争,根据C++11和C++14标准,程序将具有未定义的行为。更糟糕的是,首先,如已提到的,编译器在增加变量时生成的指令可能取决于优化级别。其次,如果num不是原子的,则编译器可能重新排序其他内存访问以围绕++num。
int main()
{
  std::unique_ptr<std::vector<int>> vec;
  int ready = 0;
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

即使我们乐观地假设 ++ready 是“原子的”,编译器会按照需要生成检查循环(如我所说,这是未定义行为,因此编译器可以将其删除、替换为无限循环等),但编译器仍可能将指针赋值甚至更糟糕的是 vector 的初始化移动到增量操作之后,导致新线程发生混乱。实际上,如果优化编译器完全删除 ready 变量和检查循环,我一点也不会感到惊讶,因为这在语言规则下不影响可观察的行为(与你的私人希望相反)。
事实上,在去年的Meeting C++会议上,我听到了两位编译器开发人员的意见,他们非常愉快地实现了使天真编写的多线程程序表现不良的优化,只要语言规则允许,在正确编写的程序中即使有轻微的性能提升。
最后,即使您不关心可移植性,编译器也是魔法般的好,您使用的CPU很可能是超标量CISC类型,并且会将指令分解为微操作,重新排序和/或进行推测执行,其程度仅受同步原语的限制,例如(在Intel上) LOCK 前缀或内存屏障,以最大化每秒操作次数。
长话短说,线程安全编程的自然责任是:
  1. 您的责任是编写具有在语言规则下定义良好的行为(特别是语言标准内存模型)的代码。
  2. 您的编译器的责任是生成在目标架构的内存模型下具有相同定义良好(可观察的)行为的机器代码。
  3. 您的CPU的责任是执行此代码,以便观察到的行为与其自身架构的内存模型兼容。
如果您想以自己的方式做,它可能只在某些情况下起作用,但请了解保修无效,您将对任何不需要的结果负责。 :-)
PS:正确编写的示例:
int main()
{
  std::unique_ptr<std::vector<int>> vec;
  std::atomic<int> ready{0}; // NOTE the use of the std::atomic template
  std::thread t{[&]
    {
       while (!ready);
       // use "vec" here
    });
  vec.reset(new std::vector<int>());
  ++ready;
  t.join();
}

这是安全的,因为:
  1. 根据语言规则,ready的检查无法被优化掉。
  2. ++ready在检查中看到ready不为零之前发生,并且其他操作不能在这些操作周围重新排序。这是因为++ready和检查是顺序一致的,在C++内存模型中又称为另一个术语,禁止了这种特定的重新排序。因此,编译器不能重新排序指令,并且还必须告诉CPU不要推迟对vec的写入,直到ready的增加。 顺序一致是语言标准中关于原子操作的最强保证。其他更小(理论上更便宜)的保证可以通过std::atomic<T>的其他方法获得,但这些方法只适合专家使用,并且可能不会被编译器开发人员过多地优化,因为它们很少被使用。

1
如果编译器无法看到ready的所有用途,它可能会将while (!ready);编译成类似于if(!ready) { while(true); }的东西。点赞:std::atomic的关键部分是更改语义以在任何时候假定异步修改。通常情况下,将其视为未定义行为允许编译器将负载提升并将存储下沉出循环。 - Peter Cordes

8

在使用单个CPU的x86计算机时,使用单个指令可以确保中断不会分裂读取/修改/写入操作,如果内存也不用作DMA缓冲区,则实际上是原子的(而C++标准没有提及线程,因此没有解决这个问题)。

在客户端桌面上很少有双处理器(例如双插槽奔腾Pro),我有效地利用了这一点,在单核机器上避免了LOCK前缀并提高了性能。

如今,它只有在多个线程都设置为相同CPU亲和性时才会对抗多个线程有所帮助,因此您担心的线程只有通过时间片到期并在同一个CPU(核心)上运行另一个线程时才会发挥作用。这是不现实的。

使用现代x86 / x64处理器,单个指令被分成几个微操作,并且还对内存读取和写入进行了缓冲。因此,运行在不同CPU上的不同线程不仅会将其视为非原子性,而且可能会看到与其从内存中读取的结果以及其认为其他线程在该时间点之前读取的结果不一致:您需要添加内存栅栏以恢复合理的行为。


2
中断仍然不会分割RMW操作,因此它们仍然会将单个线程与在同一线程中运行的信号处理程序同步。当然,这仅适用于asm使用单个指令而不是单独的load/modify/store的情况。C++11可以公开此硬件功能,但它没有(可能是因为它只在单处理器内核中与中断处理程序同步时真正有用,而不是在用户空间中与信号处理程序同步)。此外,架构没有读取-修改-写入内存目标指令。不过,在非x86上,它可以编译为类似松弛原子RMW的方式。 - Peter Cordes
是的,抱歉!我实际上没有仔细阅读。我只看到了关于解码成uops的红鱼,然后没有读完看你实际上说了什么。关于486:我记得最早的SMP是某种康柏386,但其内存排序语义与当前的x86 ISA不同。目前的x86手册甚至可能提到SMP 486。直到PPro / Athlon XP时代,它们在高性能计算(Beowulf集群)中也并不常见,我想。 - Peter Cordes
@LeoHeinsaar 是的:中断总是在汇编指令之间进行,并刷新流水线等,因此在这些指定条件下,它看起来像一个一次执行一条指令的旧CPU。只有小助手函数需要手动编写汇编代码。 - JDługosz
2
@PeterCordes 好的,明白了。假设还没有DMA/设备观察者——很抱歉没有足够的评论区空间来加入这个内容。感谢JDługosz提供的出色补充(回答和评论)。真的很好地完成了讨论。 - Leo Heinsaar
3
一个尚未提到的重点是:乱序CPU会在内部重新排列,但黄金规则是对于单个核心来说,它们保留了指令按顺序一个接一个运行的假象(包括触发上下文切换的中断)。虽然这可能导致存储值时电气上以乱序方式存储到内存中,但是所有正在运行的程序都在单个核心上运行,并跟踪它自己所做的所有重新排序,以保持这种假象。这就是为什么对于等效于 a = 1; b = a; 的汇编代码而言,您不需要内存屏障来正确加载刚刚存储的1的原因。 - Peter Cordes
显示剩余10条评论

4
不。 这只是来自《办公室》的“No”场景链接
你同意这可能是程序的一种输出吗:
示例输出:
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

如果是这样的话,那么编译器可以自由地将其作为程序的唯一可能输出方式,并以编译器想要的任何方式进行处理。例如,一个只输出100的 main() 函数。
这就是“as-if”规则。
无论输出如何,您都可以将线程同步视为相同的方式 - 如果线程A执行 num++; num--;,而线程B反复读取 num,那么可能的有效交错是线程B从未在 num++num-- 之间读取。由于该交错是有效的,因此编译器可以自由地将其作为唯一可能的交错,并完全删除增量/减量。
这里有一些有趣的含义:
while (working())
    progress++;  // atomic, global

(例如,想象有另一个线程根据进度更新进度条UI)

编译器能将其转换为:

int local = 0;
while (working())
    local++;

progress += local;

可能这是有效的。但可能不是程序员所希望的 :-(

委员会仍在研究这些内容。目前它“运行”是因为编译器不会对原子操作进行很多优化。但这正在改变。

即使progress也是易失性的,这仍然是有效的:

int local = 0;
while (working())
    local++;

while (local--)
    progress++;

:-/


这个答案似乎只回答了Richard和我在思考的次要问题。最终我们解决了它:事实证明,当不违反任何其他规则时,C++标准确实允许合并对非“volatile”原子对象的操作。两个标准讨论文档正好讨论了这一点(链接在Richard的评论中),其中一个使用相同的进度计数器示例。因此,在C++标准化防止它的方法之前,这是一个实现质量问题。 - Peter Cordes
是的,我的“不”实际上是对整个推理线的回答。如果问题只是“num++在某些编译器/实现中是否可以是原子性的”,那么答案肯定是可以的。例如,编译器可以决定将lock添加到每个操作中。或者一些编译器+单处理器组合,在这种情况下,没有重新排序(即“好旧的日子”),所有操作都是原子性的。但这有什么意义呢?你不能真正依赖它。除非你知道你正在为该系统编写代码。(即使是这样,更好的方法是在该系统上,atomic<int>不会增加任何额外的操作。因此,您仍应编写标准代码...) - tony
1
请注意,And just remove the incr/decr entirely.并不完全正确。 它仍然是对num进行获取和释放操作。 在x86上,num ++; num--可以编译为仅MFENCE,但绝对不是什么都没有。 (除非编译器的整个程序分析可以证明没有任何东西与该修改同步,并且如果一些来自之前的存储被延迟到之后加载之后,这并不重要。)例如,如果这是一个解锁并重新锁定的用例,则仍然具有两个单独的关键部分(可能使用mo_relaxed),而不是一个大的关键部分。 - Peter Cordes
@PeterCordes 是的,同意。 - tony

3

一种编译器在特定CPU架构上的输出,禁用优化(因为gcc甚至不会在优化一个快速而肮脏的示例时将++编译为add),似乎意味着以这种方式递增是原子的,并不意味着这符合标准(在尝试在线程中访问num时会导致未定义的行为),而且是错误的,因为x86中add不是原子操作。

请注意,在x86上使用lock指令前缀的原子操作(见这个相关答案)相对较重,但仍然比互斥锁要少得多,而互斥锁在这种情况下并不太合适。

以下结果来自于使用-Os编译时的clang++ 3.8。

通过引用递增整数的“常规”方式:

void inc(int& x)
{
    ++x;
}

这将编译为:
inc(int&):
    incl    (%rdi)
    retq

通过引用增加整数的方式,使用原子操作:

#include <atomic>

void inc(std::atomic<int>& x)
{
    ++x;
}

这个例子与常规方式相比并不复杂,只是在incl指令前加上了lock前缀 - 但要小心,正如先前所述,这并不便宜。仅仅因为汇编代码看起来很短,并不意味着它很快。
inc(std::atomic<int>&):
    lock            incl    (%rdi)
    retq

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接