“int num” 中 Num++ 是原子的吗?

一般来说,对于 int num,作为读-修改-写操作的 num++(或 ++num)是 不是原子弹。但是我经常看到编译器,例如 海湾合作委员会,为它生成以下代码(试试这里) :

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并强加相关的成本,因为它是原子级的) ?

更新

请注意,这个问题是 没有是否增量 原子(它不是,这是问题的开始行)。问题在于 可以是否在特定的场景中使用,也就是说,在某些情况下是否可以利用单指令的特性来避免 lock前缀的开销。而且,正如公认的答案在关于单处理器机器以及 这个答案的章节中提到的那样,其注释和其他解释中的对话是 可以的(尽管不是用 C 或 C + +)。

20945 次浏览

尝试在非 x86计算机上编译相同的代码,您将很快看到非常不同的汇编结果。

num++ 出现了之所以是原子的,是因为在 x86计算机上,递增一个32位整数实际上是原子的(假设不进行内存检索)。但是这既不能得到 c + + 标准的保证,也不太可能在不使用 x86指令集的机器上得到保证。因此,这段代码在竞态条件下并不是跨平台安全的。

即使在 x86体系结构中,也无法强有力地保证此代码不受“竞赛条件”的影响,因为 x86不会将加载和存储设置到内存中,除非特别指示这样做。因此,如果多个线程试图同时更新这个变量,它们最终可能会增加缓存的(过时的)值

因此,我们之所以使用 std::atomic<int>等等,是因为当您使用的架构不能保证基本计算的原子性时,您有一种强制编译器生成原子代码的机制。

Add 指令是 没有原子指令。它引用内存,并且两个处理器核心可能对该内存具有不同的本地缓存。

IIRC add 指令的原子变体称为 锁定 Xadd

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

基于“逆向工程”生成的装配得出结论是危险的。例如,您似乎已经在禁用优化的情况下编译了代码,否则编译器就会抛弃该变量,或者在不调用 operator++的情况下直接将1加载到该变量。因为生成的程序集可能会根据优化标志、目标 CPU 等发生显著变化,所以您的结论是基于沙子的。

另外,你认为一条汇编指令意味着一个操作是原子的想法也是错误的。这个 add在多 CPU 系统上不是原子的,即使在 x86架构上也是如此。

现在让我们进行优化:

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 + + 标准有一个“似是而非”的规则,根据这个规则,编译器可以重新排序代码,甚至可以重写代码,前提是结果具有 完全相同的观察效应(包括副作用) ,就好像它只是简单地执行了你的代码。

似是而非原则是保守的,特别是在原子方面。

考虑:

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

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 只增加一次。

在单核 x86机器上,与 CPU1上的其他代码相比,add指令通常是原子的。中断不能将单个指令从中间分离。

乱序执行需要保持指令在一个内核中按顺序执行的错觉,所以在同一个 CPU 上运行的任何指令要么在加法之前完全发生,要么在加法之后完全发生。

现代的 x86系统是多核的,所以不适用单处理器特殊情况。

如果一个人的目标是一个小型嵌入式电脑,并没有计划移动代码到其他任何东西,“添加”指令的原子性质可能会被利用。另一方面,那些本质上是原子操作的平台正变得越来越少。

(但是,如果你用 C + + 编写,这对你没有帮助。编译器没有要求 num++编译成内存目标添加或 xadd 没有 lock前缀的选项。他们可以选择将 num加载到一个寄存器中,并用一个单独的指令存储增量结果,如果您使用结果,那么很可能会这样做。)


脚注1: lock前缀甚至存在于最初的8086上,因为 I/O 设备与 CPU 同时运行; 单核系统上的驱动程序需要 lock add来自动增加设备内存中的一个值,如果设备也可以修改它,或者关于 DMA 访问。

即使你的编译器总是以原子操作的方式发出这个命令,根据 C + + 11和 C + + 14的标准,从任何其他线程同时访问 num都会构成一场数据竞赛,而且程序会有未定义行为。

但情况比这更糟。首先,如前所述,编译器在递增变量时生成的指令可能取决于优化级别。其次,如果 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是“原子的”,并且编译器根据需要生成检查循环(就像我说的,它是 UB,因此编译器可以自由地删除它,用一个无限循环替换它,等等) ,编译器仍然可能移动指针赋值,甚至更糟糕的是初始化 vector到增量操作之后的一个点,在新线程中造成混乱。实际上,如果一个编译器最佳化完全删除了 ready变量和检查循环,我一点也不会感到惊讶,因为这不会影响根据语言规则观察到的行为(与你的私人希望相反)。

事实上,在去年的 C + + 会议上,我从 编译器开发人员那里听说,只要语言规则允许,即使在正确编写的程序中看到一点点性能改进,他们也会非常乐意实现优化,使天真编写的多线程程序行为不当。

最后,即使 如果你不关心可移植性,你的编译器是神奇的好,你使用的 CPU 很可能是一个超标量 CISC 类型,将分解指令到微操作,重新排序和/或投机执行他们,在一定程度上仅限于同步原语,如(英特尔)的 LOCK前缀或内存栅栏,为了最大化操作每秒。

长话短说,线程安全编程的自然职责是:

  1. 您的职责是编写在语言规则(特别是语言标准内存模型)下具有良好定义的行为的代码。
  2. 编译器的职责是生成在目标体系结构的内存模型下具有相同定义良好(可观察)行为的机器代码。
  3. 您的 CPU 的职责是执行这些代码,以便观察到的行为与它自己的体系结构的内存模型兼容。

如果你想用你自己的方式来做,它可能只是在某些情况下工作,但要明白保修是无效的,你将完全负责任何 多余的的结果。:-)

附注: 正确的例子:

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>的其他方法,但这些肯定是专家才能使用的,而且可能不会被编译器开发人员优化太多,因为他们很少使用。

这绝对是 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 操作甚至是不可能的。在我对 原子性在 x86上的回答中,我对纯负载和纯存储进行了更详细的讨论,而这个回答关注的是读-修改-写。

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

所以 lock add dword [num], 1 原子 。运行该指令的 CPU 核心会将缓存线路固定在其私有 L1缓存中的“修改状态”中,从负载从缓存读取数据开始,直到存储将结果提交回缓存。根据 MESI 缓存一致性协议(或多核 AMD/Intel CPU 分别使用的 MOESI/MESIF 版本)的规则,这可以防止系统中的任何其他缓存在从负载到存储的任何点上拥有缓存线路的副本。因此,其他核心的操作似乎发生在之前或之后,而不是期间。

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

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

请注意,lock前缀还会将指令转换为完整的内存屏障(如 MFence) ,停止所有运行时重新排序,从而产生循序一致性。(见 杰夫 · 普雷辛的优秀博客文章。他的其他文章也都很出色,清楚地解释了 很多中关于 无锁编程,无锁编程的一些好东西,从 x86和其他硬件细节到 C + + 规则。)


在单处理器机器上,或者在单线程进程 中,实际上没有 lock前缀的单个 RMW指令是 原子指令。其他代码访问共享变量的唯一方法是让 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和后来的微架构那样将复杂的指令解码成多个简单的微操作。更多信息请参见 阿格纳 · 福格的说明书/微架构指南,许多有用的链接请参见 标签 wiki (包括 Intel 的 x86 ISA 手册,可以免费获得 PDF 格式)。


不要将目标内存模型(x86)与 C + + 内存模型混淆

允许编译时重新排序 。使用 std: : atom 所获得的另一部分是对编译时重新排序的控制,以确保只有在执行其他操作之后,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: : atom 的有用替代品。Atom 的确使编译器假设可以异步地修改内存中的值,这与 volatile类似,但是它的意义远不止于此。(实际上,对于纯负载和纯存储操作有 之间的相似性挥发性 int 到标准: : 原子与 mo _ 弛豫,但对于 RMW 没有)。此外,volatile std::atomic<int> foo不一定与 std::atomic<int> foo相同,尽管当前的编译器不会优化原子(例如两个相同值的背靠背存储) ,因此易失性原子不会改变代码生成器

将非原子变量上的数据竞争定义为“未定义的行为”,这使得编译器仍然可以将负载和接收存储提升到循环之外,还可以对多个线程可能引用的内存进行许多其他优化。(有关 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

注意序列一致性存储之后是如何需要 MFENT (一个完全屏障)的。X86通常是强顺序的,但是允许 StoreLoad 重新排序。拥有存储缓冲区对于流水线式无序 CPU 的良好性能至关重要。Jeff Preshing 的 内存重新排序在行为中被捕获显示了使用 MFence 的 没有的结果,用真实的代码显示在真实的硬件上发生的重新排序。


关于@Richard Hodges 对 将标准: : 原子 ABC0操作合并为一条 num--;指令的编译器的回答:

关于同一主题的一个单独的问答: 为什么没有编译器合并冗余的标准: : 原子写,在这里我的回答重述了我在下面写的很多东西。

当前的编译器实际上并没有这样做(目前还没有) ,但这并不是因为它们不被允许这样做。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的其他线程保持着 Synchronize With 关系,它既是一个获取加载,又是一个发布存储,不允许对该线程中的其他操作进行重新排序。对于 x86,这可能能够编译成 MFENT,而不是 lock add dword [num], 0(即 num += 0)。

正如在 PR0062中所讨论的,在编译时更积极地合并不相邻的原子操作可能是不好的(例如,一个进度计数器只在每次迭代结束时更新一次,而不是每次迭代) ,但它也可以帮助性能而不带来负面影响(例如,如果编译器可以证明另一个 shared_ptr对象在临时对象的整个生命周期中存在,那么在创建和销毁一个 shared_ptr副本时,跳过引用计数的原子 inc/dec)

当一个线程立即解锁和重新锁定时,即使 num++; num--合并也会损害锁实现的公平性。如果它实际上从来没有在高潮时释放,那么即使是硬件仲裁机制也不会给另一个线程在那个时候抓住锁的机会。


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

是的,但是..。

原子不是你想说的,你可能问错了。

增量当然是 原子弹。除非存储不对齐(而且由于您将对齐留给了编译器,所以不对齐) ,否则它必须在单个缓存行中对齐。缺少特殊的非缓存流指令,每个写操作都要通过缓存。完整的缓存行是自动读写的,没有任何不同。
当然,小于缓存的数据也是自动编写的(因为周围的缓存行是自动编写的)。

线程安全吗?

这是一个不同的问题,至少有两个好的理由来回答一个明确的 “不!”

首先,另一个核心可能在 L1中拥有该缓存行的副本(L2及以上通常是共享的,但 L1通常是每个核心!),并同时修改该值。当然,这也是原子级的,但是现在您有两个“正确”(正确的、原子级的、修改过的)值——现在哪一个才是真正正确的?
当然,中央处理器会以某种方式解决这个问题,但结果可能不是你所期望的那样。

其次,存在内存排序,或者在保证之前发生不同的措辞。关于原子指令,最重要的不是它们是 原子弹。这是命令。

您有可能强制执行一个保证,即所有发生在内存方面的事情都是以某种保证的、定义良好的顺序实现的,其中您有一个“以前发生过”的保证。这个顺序可能是“放松”(阅读为: 没有在所有)或作为您需要的严格。

例如,您可以设置一个指针指向某些数据块(比如,某些计算的结果) ,然后自动 释放“ data is ready”标志。现在,不管 获得这个标志是谁都会认为指针是有效的。事实上,它将 一直都是是一个有效的指针,没有任何不同。这是因为对指针的写操作发生在原子操作之前。

单个编译器的输出,在特定的 CPU 架构上,禁用了优化(因为 gcc 在优化 用一个简单粗暴的例子时甚至没有将 ++编译成 add) ,这似乎意味着以原子的方式递增,但这并不意味着这是标准兼容的(当你试图访问线程中的 num时会引起未定义行为) ,而且无论如何都是错误的,因为在 x86中,add没有原子的。

请注意原子(使用 lock指令前缀)在 x86(看看这个相关的答案)上相对较重,但仍然明显少于互斥锁,这在本用例中并不十分合适。

在用 -Os进行编译时,从 clang + + 3.8中得到了以下结果。

通过引用递增 int,“常规”方式:

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

这些数据汇总为:

inc(int&):
incl    (%rdi)
retq

递增通过引用传递的 int,原子方式:

#include <atomic>


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

这个示例并不比常规方法复杂多少,只是在 incl指令中添加了 lock前缀——但是要注意,正如前面所说的,没有很便宜。装配看起来短并不意味着它快。

inc(std::atomic<int>&):
lock            incl    (%rdi)
retq

Https://www.youtube.com/watch?v=31g0ye61plq (这只是一个链接到“办公室”中的“不”场景)

你同意这可能是这个项目的结果吗:

样本输出:

100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100

如果是这样,那么编译器可以自由地让 只有以编译器希望的任何方式输出程序。(只发出100个信号的主信号)。

这是“似是而非”的规则。

不管输出如何,你都可以用同样的方式来考虑线程同步——如果线程 A 重复读取 num++; num--;,而线程 B 重复读取 num,那么一个可能的有效交织就是线程 B 从不在 num++num--之间读取。由于交错是有效的,编译器可以自由地使 只有可以交错。然后完全删除 incr/decr。

这里有一些有趣的含义:

while (working())
progress++;  // atomic, global

(即想象一些其他线程更新基于 progress的进度条用户界面)

编译器能否将其转换为:

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


progress += local;

可能是有效的,但可能不是程序员所期望的:

委员会还在研究这些东西。目前它“工作”是因为编译器不太优化原子。但这种情况正在改变。

即使 progress也是不稳定的,这也是有效的:

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


while (local--)
progress++;

:-/

在 x86计算机只有一个 CPU 的时代,使用单一指令可以确保中断不会拆分读/修改/写操作,如果内存也不会被用作 DMA 缓冲区,那么它实际上是原子的(而且 C + + 在标准中没有提到线程,所以这个问题没有得到解决)。

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

现在,它只能帮助处理设置为相同 CPU 关联的多个线程,因此您所担心的线程只能通过时间切片到期并在同一 CPU (核心)上运行另一个线程来发挥作用。这不现实。

使用现代的 x86/x64处理器,单个指令被分解成多个 微观行动,而且内存读写也得到了缓冲。因此,运行在不同 CPU 上的不同线程不仅会认为这是非原子的,而且可能会看到不一致的结果,涉及到它从内存读取的内容以及它假定其他线程已经读取到该时间点的内容: 您需要添加 记忆栅栏来恢复正常的行为。

当编译器只使用单个指令进行增量并且计算机为单线程时,代码是安全的。^^