'int num' 的 num++ 可以是原子的吗?
Can num++ be atomic for 'int num'?
一般来说,对于int num
,num++
(或++num
),作为读-改-写操作,不是原子的.但是我经常看到编译器,比如GCC, generate the following code for it (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
因为对应num++
的第5行是一条指令,那么在这种情况下,我们是否可以断定num++
是原子的?
如果是这样,是否意味着如此生成的 num++
可以用于并发(多线程)场景而没有任何数据竞争的危险(即我们不需要制作它,例如 std::atomic<int>
并强加相关成本,因为它无论如何都是原子的)?
更新
注意这个问题是 not whether increment is atomic (it's not and that was and is and is the opening line of the question) .它是否 可以 在特定情况下,即在某些情况下是否可以利用单指令性质来避免 lock
前缀的开销。而且,正如接受的答案在关于单处理器机器的部分以及 中提到的那样,其评论中的对话和其他人解释说,它可以(尽管不能使用 C 或C++).
尝试在非 x86 机器上编译相同的代码,您很快就会看到截然不同的汇编结果。
num++
出现是原子的原因是因为在 x86 机器上,递增 32 位整数实际上是原子的(假设没有内存检索需要地方)。但这既不是 c++ 标准所保证的,也不可能是不使用 x86 指令集的机器上的情况。所以这段代码在竞争条件下不是跨平台安全的。
即使在 x86 架构上,您也无法有力地保证此代码不会出现竞争条件,因为 x86 不会设置加载和存储到内存,除非明确指示这样做。因此,如果多个线程试图同时更新此变量,它们最终可能会增加缓存(过时)的值
那么,我们有 std::atomic<int>
等等的原因是,当您使用无法保证基本计算的原子性的架构时,您有一种机制可以强制编译器生成原子代码。
加法指令不是原子的。它引用内存,并且两个处理器内核可能具有该内存的不同本地缓存。
IIRC add 指令的原子变量被称为 lock xadd
Since line 5, which corresponds to num++ is one instruction, can we conclude that num++ is atomic in this case?
根据 "reverse engineering" 生成的程序集得出结论是危险的。例如,您似乎在禁用优化的情况下编译了代码,否则编译器会丢弃该变量或直接将 1 加载到它而不调用 operator++
。因为生成的程序集可能会有很大的变化,基于优化标志,目标CPU等,你的结论是根据sand.
此外,您认为一条汇编指令意味着一个操作是原子操作的想法也是错误的。这个 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
现在,每次修改是:-
- 在另一个线程中可观察到,并且
- 尊重其他线程中发生的类似修改。
原子性不仅仅在指令级别,它涉及从处理器到缓存再到内存再返回的整个管道。
更多信息
关于 std::atomic
s 更新的优化效果。
c++ 标准有 'as if' 规则,允许编译器重新排序代码,甚至重写代码,前提是结果具有 完全相同的可观察性 效果(包括副作用)就像它只是执行了您的代码一样。
as-if 规则是保守的,尤其是涉及原子。
考虑:
void incdec(int& num) {
++num;
--num;
}
因为没有互斥锁、原子或任何其他影响线程间排序的构造,我认为编译器可以自由地将此函数重写为 NOP,例如:
void incdec(int&) {
// nada
}
这是因为在c++内存模型中,不可能有另外一个线程观察自增的结果。如果 num
是 volatile
(可能会影响硬件行为),那当然会有所不同。但是在这种情况下,这个函数将是唯一修改这个内存的函数(否则程序是错误的)。
然而,这是不同的球赛:
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 机器上,add
指令通常相对于 CPU1 上的其他代码是原子的。中断不能在中间拆分单个指令。
需要乱序执行来保持指令在单个内核中按顺序一次执行一条指令的错觉,因此任何指令 运行 在同一个 CPU 上都会发生完全在添加之前或之后。
现代 x86 系统是多核的,因此单处理器特殊情况不适用。
如果目标是小型嵌入式 PC,并且不打算将代码移动到其他任何地方,则可以利用 "add" 指令的原子性质。另一方面,操作本身具有原子性的平台变得越来越稀缺。
(不过,如果你用 C++ 编写,这对你没有帮助。编译器没有选项要求 num++
编译到内存目标添加或 xadd 没有 lock
前缀。他们可以选择将 num
加载到寄存器中并使用单独的指令存储增量结果,如果您使用该结果,他们可能会这样做。)
脚注 1:lock
前缀甚至存在于原始 8086 上,因为 I/O 设备与 CPU 同时运行;单核系统上的驱动程序需要 lock add
以原子方式增加设备内存中的值,如果设备也可以修改它,或者关于 DMA 访问。
即使您的编译器始终将其作为原子操作发出,根据 C++11 和 C++14 标准,从任何其他线程同时访问 num
也会构成数据竞争,并且程序会有未定义的行为。
但比这更糟。首先,如前所述,编译器在递增变量时生成的指令可能取决于优化级别。其次,如果 num
不是原子的,编译器可能会在 ++num
周围重新排序 other 内存访问,例如
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
是 "atomic",并且编译器会根据需要生成检查循环(正如我所说,它是 UB,因此编译器可以自由删除它,将其替换为无限循环等),编译器可能仍会移动指针赋值,甚至更糟的是将 vector
的初始化移动到增量操作后的某个点,从而导致新线程出现混乱。实际上,如果优化编译器完全删除 ready
变量和检查循环,我一点也不会感到惊讶,因为这不会影响语言规则下的可观察行为(与您的个人希望相反)。
事实上,在去年的 Meeting C++ 会议上,我从 两个 编译器开发人员那里听说,他们非常乐意实施优化,使天真编写的 multi-threaded 程序行为不端, 只要语言规则允许,即使在正确编写的程序中看到了很小的性能改进。
最后,即使如果你不关心可移植性,而且你的编译器非常好,你使用的CPU很可能是超标量CISC键入并将指令分解为 micro-ops,重新排序 and/or 推测性地执行它们,在一定程度上仅受限于同步原语,例如(在 Intel 上)LOCK
前缀或内存栅栏,以便最大化每秒操作数。
长话短说,thread-safe 编程的自然职责是:
- 您的职责是编写在语言规则(尤其是语言标准内存模型)下具有 well-defined 行为的代码。
- 您的编译器的职责是生成在目标体系结构的内存模型下具有相同 well-defined(可观察)行为的机器代码。
- 您的 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();
}
这是安全的,因为:
- 根据语言规则无法优化
ready
的检查。
-
++ready
happens-before 将 ready
视为非零的检查,并且其他操作不能围绕这些操作重新排序。这是因为 ++ready
和检查 顺序一致 ,这是 C++ 内存模型中描述的另一个术语,它禁止这种特定的重新排序。因此编译器不能重新排序指令,并且还必须告诉 CPU 它不能,例如将对 vec
的写入推迟到 ready
的增量之后。 顺序一致是语言标准中关于原子性的最强保证。可提供较小(理论上更便宜)的保证,例如通过 std::atomic<T>
的其他方法,但这些绝对仅供专家使用,编译器开发人员可能不会对其进行太多优化,因为它们很少使用。
这绝对是 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 的内存操作可以在加载和存储之间变得全局可见。 IE。循环中的两个线程 运行ning add dword [num], 1
会踩到彼此的商店。 (请参阅 以获得漂亮的图表)。在两个线程中的每一个增加 40k 之后,计数器在真正的多核 x86 硬件上可能只增加了 ~60k(而不是 80k)。
“原子”,来自希腊语,意思是不可分割的,意思是没有观察者可以看到作为单独步骤的操作。同时对所有位进行物理/电气瞬时发生只是加载或存储实现此目的的一种方法,但这对于 ALU 操作来说甚至是不可能的。 我详细介绍了纯加载和纯存储在我对 的回答中,而这个回答侧重于读-修改-写。
lock
prefix can be applied to many read-modify-write (memory destination) instructions to make the entire operation atomic with respect to all possible observers in the system (other cores and DMA devices, not an oscilloscope hooked up to the CPU pins). That is why it exists. (See also this Q&A).
所以lock add dword [num], 1
是原子。 CPU 核心 运行 该指令将使缓存行在其私有 L1 缓存中保持修改状态,从加载从缓存中读取数据直到存储将其结果提交回缓存。根据 MESI cache coherency protocol(或多线程使用的 MOESI/MESIF 版本)的规则,这可以防止系统中的任何其他缓存在从加载到存储的任何时间点拥有缓存行的副本。核心 AMD/Intel CPUs)。因此,其他核心的操作似乎发生在之前或之后,而不是期间。
没有 lock
前缀,另一个核心可以获取缓存行的所有权并在我们加载之后但在我们的存储之前对其进行修改,以便其他存储在我们的加载和存储之间变得全局可见。其他几个答案弄错了,并声称如果没有 lock
你会得到同一缓存行的相互冲突的副本。这在具有一致缓存的系统中永远不会发生。
(如果 lock
ed 指令在跨越两个缓存行的内存上运行,则需要做更多的工作来确保对对象的两个部分的更改在传播到所有观察者时保持原子性,所以没有观察者可以看到撕裂。CPU 可能必须锁定整个内存总线,直到数据到达内存。不要错位你的原子变量!)
请注意,lock
前缀还会将指令变成完整的内存屏障(如 MFENCE), stopping all run-time reordering and thus giving sequential consistency. (See Jeff Preshing's excellent blog post. His other posts are all excellent, too, and clearly explain a lot of good stuff about lock-free programming,从 x86 和其他硬件细节到 C++ 规则。)
在单处理器机器上,或在单线程进程中,单个RMW instruction actually is atomic without a lock
prefix. The only way for other code to access the shared variable is for the CPU to do a context switch, which can't happen in the middle of an instruction. So a plain dec dword [num]
can synchronize between a single-threaded program and its signal handlers, or in a multi-threaded program running on a single-core machine. See ,以及它下面的注释,我在其中对此进行了更详细的解释。
回到 C++:
使用 num++
而不告诉编译器您需要它来编译为单个读-修改-写实现是完全虚假的:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
如果您稍后使用 num
的值,则很有可能:编译器会在递增后将其保存在寄存器中。因此,即使您检查 num++
如何自行编译,更改周围的代码也会影响它。
(如果以后不需要该值,则首选 inc dword [num]
;现代 x86 CPUs 将 运行 内存目标 RMW 指令至少与使用三个一样有效单独的说明。有趣的事实:gcc -O3 -m32 -mtune=i586
will actually emit this, because (Pentium) P5's superscalar pipeline didn't decode complex instructions to multiple simple micro-operations the way P6 and later microarchitectures do. See the Agner Fog's instruction tables / microarchitecture guide for more info, and the x86 标记 wiki 以获得许多有用的链接(包括英特尔的 x86 ISA 手册,可免费获得 PDF 格式)。
不要混淆目标内存模型 (x86) 和 C++ 内存模型
Compile-time reordering 允许。 std::atomic 的另一部分是控制编译时重新排序,以确保您的 num++
仅在其他操作后才变得全局可见。
经典示例:将一些数据存储到缓冲区中以供另一个线程查看,然后设置标志。即使 x86 确实免费获取 loads/release 存储,您仍然必须告诉编译器不要使用 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上查看源代码+格式化好的汇编语言代码。您可以 select 其他目标架构,包括 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--;
指令 的回答的评论中的讨论:
关于同一主题的单独问答: ,我的回答重申了我在下面写的很多内容。
当前的编译器实际上(还)没有这样做,但不是因为不允许这样做。 C++ WG21/P0062R1: When should compilers optimize atomics? discusses the expectation that many programmers have that compilers won't make "surprising" optimizations, and what the standard can do to give programmers control. N4455 讨论了许多可以优化的例子,包括这个。它指出内联和常量传播可以引入像 fetch_or(0)
这样的东西,它可能会变成 load()
(但仍然具有获取和释放语义),即使原始来源没有有任何明显冗余的原子操作。
编译器(还)不这样做的真正原因是:(1) 没有人编写复杂的代码来让编译器安全地执行此操作(永远不会出错),以及 (2) 它可能违反 principle of least surprise. Lock-free code is hard enough to write correctly in the first place. So don't be casual in your use of atomic weapons: they aren't cheap and don't optimize much. It's not always easy easy to avoid redundant atomic operations with std::shared_ptr<T>
, though, since there's no non-atomic version of it (although one of the answers here 提供了一种为 gcc 定义 shared_ptr_unsynchronized<T>
的简单方法。
回到 num++; num-=2;
编译,就好像它是 num--
一样:
编译器 被允许 这样做,除非 num
是 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 中所讨论的,在编译时更积极地合并非相邻的原子操作可能是不好的(例如,进度计数器只在最后更新一次而不是每次迭代更新),但它可以也有助于提高性能而不会带来负面影响(例如,如果编译器可以证明另一个 shared_ptr
对象在整个生命周期内存在,则在创建和销毁 shared_ptr
的副本时跳过引用计数的原子递增/递减临时的。)
甚至 num++; num--
当一个线程立即解锁并重新锁定时,合并可能会损害锁实现的公平性。如果它从未真正在 asm 中释放,即使是硬件仲裁机制也不会给另一个线程此时获取锁的机会。
使用当前的 gcc6.2 和 clang3.9,在最明显可优化的情况下,即使使用 memory_order_relaxed
,您仍然可以获得单独的 lock
ed 操作。 (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
是的,但是...
Atomic 不是你想说的。你可能问错了。
增量当然是原子的。除非存储未对齐(并且由于您将对齐留给了编译器,所以它不是),它必须在单个缓存行内对齐。缺少特殊的非缓存流指令,每次写入都通过缓存。完整的缓存行正在以原子方式读取和写入,没有任何不同。
当然,小于缓存行的数据也是原子写入的(因为周围的缓存行是)。
它是线程安全的吗?
这是一个不同的问题,至少有两个很好的理由可以明确回答 "No!".
首先,另一个核心可能在 L1 中拥有该缓存行的副本(L2 及以上通常是共享的,但 L1 通常是每个核心!),并同时修改该值。当然这也是原子发生的,但是现在你有两个 "correct"(正确地,原子地,修改过的)值——现在哪一个是真正正确的?
CPU 当然会以某种方式解决它。但结果可能不是你所期望的。
其次,存在内存排序,或者措辞不同的先行发生保证。原子指令最重要的不在于它们是原子。正在下单。
您可以强制保证内存中发生的所有事情都按照某种有保证的、定义明确的顺序实现,您有 "happened before" 保证。此顺序可能是 "relaxed"(完全读作:none)或您需要的严格程度。
例如,您可以设置一个指向某些数据块的指针(比如某些计算的结果),然后自动释放 "data is ready" 标志。现在,无论谁获得这个标志,都会被引导认为指针是有效的。事实上,它 总是 是一个有效的指针,从来没有任何不同。那是因为对指针的写入发生在原子操作之前。
单个编译器的输出,在特定的 CPU 架构上,禁用了优化(因为 gcc 在优化 in a quick&dirty example 时甚至不编译 ++
到 add
), 似乎暗示以这种方式递增是原子的并不意味着这是符合标准的(当你试图在线程中访问 num
时会导致未定义的行为),而且无论如何都是错误的,因为 add
在 x86 中 不是 原子的。
请注意,原子(使用 lock
指令前缀)在 x86 (see this relevant answer) 上相对较重,但仍然明显少于互斥量,这在这种用途中不太合适-案例.
使用 -Os
.
编译时,以下结果取自 clang++ 3.8
通过引用递增 int,"regular" 方式:
void inc(int& x)
{
++x;
}
编译成:
inc(int&):
incl (%rdi)
retq
递增通过引用传递的 int,原子方式:
#include <atomic>
void inc(std::atomic<int>& x)
{
++x;
}
这个例子并不比常规方法复杂多少,只是将 lock
前缀添加到 incl
指令 - 但请注意,如前所述,这是 不便宜。仅仅因为程序集看起来很短并不意味着它很快。
inc(std::atomic<int>&):
lock incl (%rdi)
retq
没有。
https://www.youtube.com/watch?v=31g0YE61PLQ
(这只是 "The Office" 到 "No" 场景的 link)
您是否同意这将是程序的可能输出:
示例输出:
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
如果是这样,那么编译器可以自由地仅为程序输出可能的输出,以编译器想要的任何方式。即一个只输出 100s 的 main()。
这是"as-if"规则。
并且无论输出如何,您都可以以相同的方式考虑线程同步 - 如果线程 A 执行 num++; num--;
并且线程 B 重复读取 num
,则可能的有效交错是线程 B 从不在 num++
和 num--
之间读取。由于该交错是有效的,编译器可以自由地使 仅 可能的交错。并完全删除 incr/decr。
这里有一些有趣的含义:
while (working())
progress++; // atomic, global
(即假设其他线程根据 progress
更新进度条 UI)
编译器可以把它变成:
int local = 0;
while (working())
local++;
progress += local;
可能是正确的。但可能不是程序员所希望的:-(
委员会仍在努力解决这个问题。目前它 "works" 因为编译器没有太多优化原子。但这正在改变。
即使 progress
也是不稳定的,这仍然有效:
int local = 0;
while (working())
local++;
while (local--)
progress++;
:-/
在 x86 计算机只有一个 CPU 的那一天,使用一条指令确保中断不会拆分 read/modify/write 并且如果内存不会用作 DMA 缓冲区同样,它实际上是原子的(并且 C++ 没有在标准中提到线程,所以这没有得到解决)。
当客户桌面上很少有双处理器(例如双路 Pentium Pro)时,我有效地使用它来避免单核机器上的 LOCK 前缀并提高性能。
今天,它只会对所有设置为相同 CPU 关联的多个线程有所帮助,因此您担心的线程只会通过时间片到期和 运行 发挥作用同一CPU(核心)上的另一个线程。那是不现实的。
对于现代 x86/x64 处理器,单个指令被分解为多个 微操作 并且内存读写被缓冲。因此,不同 CPUs 上的不同线程 运行 不仅会将其视为非原子,而且可能会看到关于它从内存中读取的内容以及它假定其他线程已读取到该时间点的内容的不一致结果:您需要添加 内存栅栏 才能恢复理智的行为。
当您的编译器仅使用一条指令进行增量并且您的机器是单线程时,您的代码是安全的。 ^^
一般来说,对于int num
,num++
(或++num
),作为读-改-写操作,不是原子的.但是我经常看到编译器,比如GCC, generate the following code for it (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
因为对应num++
的第5行是一条指令,那么在这种情况下,我们是否可以断定num++
是原子的?
如果是这样,是否意味着如此生成的 num++
可以用于并发(多线程)场景而没有任何数据竞争的危险(即我们不需要制作它,例如 std::atomic<int>
并强加相关成本,因为它无论如何都是原子的)?
更新
注意这个问题是 not whether increment is atomic (it's not and that was and is and is the opening line of the question) .它是否 可以 在特定情况下,即在某些情况下是否可以利用单指令性质来避免 lock
前缀的开销。而且,正如接受的答案在关于单处理器机器的部分以及
尝试在非 x86 机器上编译相同的代码,您很快就会看到截然不同的汇编结果。
num++
出现是原子的原因是因为在 x86 机器上,递增 32 位整数实际上是原子的(假设没有内存检索需要地方)。但这既不是 c++ 标准所保证的,也不可能是不使用 x86 指令集的机器上的情况。所以这段代码在竞争条件下不是跨平台安全的。
即使在 x86 架构上,您也无法有力地保证此代码不会出现竞争条件,因为 x86 不会设置加载和存储到内存,除非明确指示这样做。因此,如果多个线程试图同时更新此变量,它们最终可能会增加缓存(过时)的值
那么,我们有 std::atomic<int>
等等的原因是,当您使用无法保证基本计算的原子性的架构时,您有一种机制可以强制编译器生成原子代码。
加法指令不是原子的。它引用内存,并且两个处理器内核可能具有该内存的不同本地缓存。
IIRC add 指令的原子变量被称为 lock xadd
Since line 5, which corresponds to num++ is one instruction, can we conclude that num++ is atomic in this case?
根据 "reverse engineering" 生成的程序集得出结论是危险的。例如,您似乎在禁用优化的情况下编译了代码,否则编译器会丢弃该变量或直接将 1 加载到它而不调用 operator++
。因为生成的程序集可能会有很大的变化,基于优化标志,目标CPU等,你的结论是根据sand.
此外,您认为一条汇编指令意味着一个操作是原子操作的想法也是错误的。这个 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
现在,每次修改是:-
- 在另一个线程中可观察到,并且
- 尊重其他线程中发生的类似修改。
原子性不仅仅在指令级别,它涉及从处理器到缓存再到内存再返回的整个管道。
更多信息
关于 std::atomic
s 更新的优化效果。
c++ 标准有 'as if' 规则,允许编译器重新排序代码,甚至重写代码,前提是结果具有 完全相同的可观察性 效果(包括副作用)就像它只是执行了您的代码一样。
as-if 规则是保守的,尤其是涉及原子。
考虑:
void incdec(int& num) {
++num;
--num;
}
因为没有互斥锁、原子或任何其他影响线程间排序的构造,我认为编译器可以自由地将此函数重写为 NOP,例如:
void incdec(int&) {
// nada
}
这是因为在c++内存模型中,不可能有另外一个线程观察自增的结果。如果 num
是 volatile
(可能会影响硬件行为),那当然会有所不同。但是在这种情况下,这个函数将是唯一修改这个内存的函数(否则程序是错误的)。
然而,这是不同的球赛:
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 机器上,add
指令通常相对于 CPU1 上的其他代码是原子的。中断不能在中间拆分单个指令。
需要乱序执行来保持指令在单个内核中按顺序一次执行一条指令的错觉,因此任何指令 运行 在同一个 CPU 上都会发生完全在添加之前或之后。
现代 x86 系统是多核的,因此单处理器特殊情况不适用。
如果目标是小型嵌入式 PC,并且不打算将代码移动到其他任何地方,则可以利用 "add" 指令的原子性质。另一方面,操作本身具有原子性的平台变得越来越稀缺。
(不过,如果你用 C++ 编写,这对你没有帮助。编译器没有选项要求 num++
编译到内存目标添加或 xadd 没有 lock
前缀。他们可以选择将 num
加载到寄存器中并使用单独的指令存储增量结果,如果您使用该结果,他们可能会这样做。)
脚注 1:lock
前缀甚至存在于原始 8086 上,因为 I/O 设备与 CPU 同时运行;单核系统上的驱动程序需要 lock add
以原子方式增加设备内存中的值,如果设备也可以修改它,或者关于 DMA 访问。
即使您的编译器始终将其作为原子操作发出,根据 C++11 和 C++14 标准,从任何其他线程同时访问 num
也会构成数据竞争,并且程序会有未定义的行为。
但比这更糟。首先,如前所述,编译器在递增变量时生成的指令可能取决于优化级别。其次,如果 num
不是原子的,编译器可能会在 ++num
周围重新排序 other 内存访问,例如
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
是 "atomic",并且编译器会根据需要生成检查循环(正如我所说,它是 UB,因此编译器可以自由删除它,将其替换为无限循环等),编译器可能仍会移动指针赋值,甚至更糟的是将 vector
的初始化移动到增量操作后的某个点,从而导致新线程出现混乱。实际上,如果优化编译器完全删除 ready
变量和检查循环,我一点也不会感到惊讶,因为这不会影响语言规则下的可观察行为(与您的个人希望相反)。
事实上,在去年的 Meeting C++ 会议上,我从 两个 编译器开发人员那里听说,他们非常乐意实施优化,使天真编写的 multi-threaded 程序行为不端, 只要语言规则允许,即使在正确编写的程序中看到了很小的性能改进。
最后,即使如果你不关心可移植性,而且你的编译器非常好,你使用的CPU很可能是超标量CISC键入并将指令分解为 micro-ops,重新排序 and/or 推测性地执行它们,在一定程度上仅受限于同步原语,例如(在 Intel 上)LOCK
前缀或内存栅栏,以便最大化每秒操作数。
长话短说,thread-safe 编程的自然职责是:
- 您的职责是编写在语言规则(尤其是语言标准内存模型)下具有 well-defined 行为的代码。
- 您的编译器的职责是生成在目标体系结构的内存模型下具有相同 well-defined(可观察)行为的机器代码。
- 您的 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();
}
这是安全的,因为:
- 根据语言规则无法优化
ready
的检查。 -
++ready
happens-before 将ready
视为非零的检查,并且其他操作不能围绕这些操作重新排序。这是因为++ready
和检查 顺序一致 ,这是 C++ 内存模型中描述的另一个术语,它禁止这种特定的重新排序。因此编译器不能重新排序指令,并且还必须告诉 CPU 它不能,例如将对vec
的写入推迟到ready
的增量之后。 顺序一致是语言标准中关于原子性的最强保证。可提供较小(理论上更便宜)的保证,例如通过std::atomic<T>
的其他方法,但这些绝对仅供专家使用,编译器开发人员可能不会对其进行太多优化,因为它们很少使用。
这绝对是 C++ 定义为导致未定义行为的数据竞争,即使一个编译器碰巧生成的代码在某些目标机器上执行了您希望的操作。您需要使用 std::atomic
以获得可靠的结果,但如果您不关心重新排序,则可以将其与 memory_order_relaxed
一起使用。请参阅下面的一些示例代码和使用 fetch_add
.
但首先,问题的汇编语言部分:
Since num++ is one instruction (
add dword [num], 1
), can we conclude that num++ is atomic in this case?
内存目标指令(纯存储除外)是在多个内部步骤中发生的读取-修改-写入操作。没有修改架构寄存器,但是 CPU 在通过其 ALU 发送数据时必须在内部保存数据。实际的寄存器文件只是内部数据存储的一小部分,即使是最简单的CPU,锁存器将一个阶段的输出作为另一阶段的输入,等等
来自其他 CPU 的内存操作可以在加载和存储之间变得全局可见。 IE。循环中的两个线程 运行ning add dword [num], 1
会踩到彼此的商店。 (请参阅
“原子”,来自希腊语,意思是不可分割的,意思是没有观察者可以看到作为单独步骤的操作。同时对所有位进行物理/电气瞬时发生只是加载或存储实现此目的的一种方法,但这对于 ALU 操作来说甚至是不可能的。 我详细介绍了纯加载和纯存储在我对
lock
prefix can be applied to many read-modify-write (memory destination) instructions to make the entire operation atomic with respect to all possible observers in the system (other cores and DMA devices, not an oscilloscope hooked up to the CPU pins). That is why it exists. (See also this Q&A).
所以lock add dword [num], 1
是原子。 CPU 核心 运行 该指令将使缓存行在其私有 L1 缓存中保持修改状态,从加载从缓存中读取数据直到存储将其结果提交回缓存。根据 MESI cache coherency protocol(或多线程使用的 MOESI/MESIF 版本)的规则,这可以防止系统中的任何其他缓存在从加载到存储的任何时间点拥有缓存行的副本。核心 AMD/Intel CPUs)。因此,其他核心的操作似乎发生在之前或之后,而不是期间。
没有 lock
前缀,另一个核心可以获取缓存行的所有权并在我们加载之后但在我们的存储之前对其进行修改,以便其他存储在我们的加载和存储之间变得全局可见。其他几个答案弄错了,并声称如果没有 lock
你会得到同一缓存行的相互冲突的副本。这在具有一致缓存的系统中永远不会发生。
(如果 lock
ed 指令在跨越两个缓存行的内存上运行,则需要做更多的工作来确保对对象的两个部分的更改在传播到所有观察者时保持原子性,所以没有观察者可以看到撕裂。CPU 可能必须锁定整个内存总线,直到数据到达内存。不要错位你的原子变量!)
请注意,lock
前缀还会将指令变成完整的内存屏障(如 MFENCE), stopping all run-time reordering and thus giving sequential consistency. (See Jeff Preshing's excellent blog post. His other posts are all excellent, too, and clearly explain a lot of good stuff about lock-free programming,从 x86 和其他硬件细节到 C++ 规则。)
在单处理器机器上,或在单线程进程中,单个RMW instruction actually is atomic without a lock
prefix. The only way for other code to access the shared variable is for the CPU to do a context switch, which can't happen in the middle of an instruction. So a plain dec dword [num]
can synchronize between a single-threaded program and its signal handlers, or in a multi-threaded program running on a single-core machine. See
回到 C++:
使用 num++
而不告诉编译器您需要它来编译为单个读-修改-写实现是完全虚假的:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
如果您稍后使用 num
的值,则很有可能:编译器会在递增后将其保存在寄存器中。因此,即使您检查 num++
如何自行编译,更改周围的代码也会影响它。
(如果以后不需要该值,则首选 inc dword [num]
;现代 x86 CPUs 将 运行 内存目标 RMW 指令至少与使用三个一样有效单独的说明。有趣的事实:gcc -O3 -m32 -mtune=i586
will actually emit this, because (Pentium) P5's superscalar pipeline didn't decode complex instructions to multiple simple micro-operations the way P6 and later microarchitectures do. See the Agner Fog's instruction tables / microarchitecture guide for more info, and the x86 标记 wiki 以获得许多有用的链接(包括英特尔的 x86 ISA 手册,可免费获得 PDF 格式)。
不要混淆目标内存模型 (x86) 和 C++ 内存模型
Compile-time reordering 允许。 std::atomic 的另一部分是控制编译时重新排序,以确保您的 num++
仅在其他操作后才变得全局可见。
经典示例:将一些数据存储到缓冲区中以供另一个线程查看,然后设置标志。即使 x86 确实免费获取 loads/release 存储,您仍然必须告诉编译器不要使用 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上查看源代码+格式化好的汇编语言代码。您可以 select 其他目标架构,包括 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--;
指令 的回答的评论中的讨论:
关于同一主题的单独问答:
当前的编译器实际上(还)没有这样做,但不是因为不允许这样做。 C++ WG21/P0062R1: When should compilers optimize atomics? discusses the expectation that many programmers have that compilers won't make "surprising" optimizations, and what the standard can do to give programmers control. N4455 讨论了许多可以优化的例子,包括这个。它指出内联和常量传播可以引入像 fetch_or(0)
这样的东西,它可能会变成 load()
(但仍然具有获取和释放语义),即使原始来源没有有任何明显冗余的原子操作。
编译器(还)不这样做的真正原因是:(1) 没有人编写复杂的代码来让编译器安全地执行此操作(永远不会出错),以及 (2) 它可能违反 principle of least surprise. Lock-free code is hard enough to write correctly in the first place. So don't be casual in your use of atomic weapons: they aren't cheap and don't optimize much. It's not always easy easy to avoid redundant atomic operations with std::shared_ptr<T>
, though, since there's no non-atomic version of it (although one of the answers here 提供了一种为 gcc 定义 shared_ptr_unsynchronized<T>
的简单方法。
回到 num++; num-=2;
编译,就好像它是 num--
一样:
编译器 被允许 这样做,除非 num
是 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 中所讨论的,在编译时更积极地合并非相邻的原子操作可能是不好的(例如,进度计数器只在最后更新一次而不是每次迭代更新),但它可以也有助于提高性能而不会带来负面影响(例如,如果编译器可以证明另一个 shared_ptr
对象在整个生命周期内存在,则在创建和销毁 shared_ptr
的副本时跳过引用计数的原子递增/递减临时的。)
甚至 num++; num--
当一个线程立即解锁并重新锁定时,合并可能会损害锁实现的公平性。如果它从未真正在 asm 中释放,即使是硬件仲裁机制也不会给另一个线程此时获取锁的机会。
使用当前的 gcc6.2 和 clang3.9,在最明显可优化的情况下,即使使用 memory_order_relaxed
,您仍然可以获得单独的 lock
ed 操作。 (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
是的,但是...
Atomic 不是你想说的。你可能问错了。
增量当然是原子的。除非存储未对齐(并且由于您将对齐留给了编译器,所以它不是),它必须在单个缓存行内对齐。缺少特殊的非缓存流指令,每次写入都通过缓存。完整的缓存行正在以原子方式读取和写入,没有任何不同。
当然,小于缓存行的数据也是原子写入的(因为周围的缓存行是)。
它是线程安全的吗?
这是一个不同的问题,至少有两个很好的理由可以明确回答 "No!".
首先,另一个核心可能在 L1 中拥有该缓存行的副本(L2 及以上通常是共享的,但 L1 通常是每个核心!),并同时修改该值。当然这也是原子发生的,但是现在你有两个 "correct"(正确地,原子地,修改过的)值——现在哪一个是真正正确的?
CPU 当然会以某种方式解决它。但结果可能不是你所期望的。
其次,存在内存排序,或者措辞不同的先行发生保证。原子指令最重要的不在于它们是原子。正在下单。
您可以强制保证内存中发生的所有事情都按照某种有保证的、定义明确的顺序实现,您有 "happened before" 保证。此顺序可能是 "relaxed"(完全读作:none)或您需要的严格程度。
例如,您可以设置一个指向某些数据块的指针(比如某些计算的结果),然后自动释放 "data is ready" 标志。现在,无论谁获得这个标志,都会被引导认为指针是有效的。事实上,它 总是 是一个有效的指针,从来没有任何不同。那是因为对指针的写入发生在原子操作之前。
单个编译器的输出,在特定的 CPU 架构上,禁用了优化(因为 gcc 在优化 in a quick&dirty example 时甚至不编译 ++
到 add
), 似乎暗示以这种方式递增是原子的并不意味着这是符合标准的(当你试图在线程中访问 num
时会导致未定义的行为),而且无论如何都是错误的,因为 add
在 x86 中 不是 原子的。
请注意,原子(使用 lock
指令前缀)在 x86 (see this relevant answer) 上相对较重,但仍然明显少于互斥量,这在这种用途中不太合适-案例.
使用 -Os
.
通过引用递增 int,"regular" 方式:
void inc(int& x)
{
++x;
}
编译成:
inc(int&):
incl (%rdi)
retq
递增通过引用传递的 int,原子方式:
#include <atomic>
void inc(std::atomic<int>& x)
{
++x;
}
这个例子并不比常规方法复杂多少,只是将 lock
前缀添加到 incl
指令 - 但请注意,如前所述,这是 不便宜。仅仅因为程序集看起来很短并不意味着它很快。
inc(std::atomic<int>&):
lock incl (%rdi)
retq
没有。 https://www.youtube.com/watch?v=31g0YE61PLQ (这只是 "The Office" 到 "No" 场景的 link)
您是否同意这将是程序的可能输出:
示例输出:
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
100
如果是这样,那么编译器可以自由地仅为程序输出可能的输出,以编译器想要的任何方式。即一个只输出 100s 的 main()。
这是"as-if"规则。
并且无论输出如何,您都可以以相同的方式考虑线程同步 - 如果线程 A 执行 num++; num--;
并且线程 B 重复读取 num
,则可能的有效交错是线程 B 从不在 num++
和 num--
之间读取。由于该交错是有效的,编译器可以自由地使 仅 可能的交错。并完全删除 incr/decr。
这里有一些有趣的含义:
while (working())
progress++; // atomic, global
(即假设其他线程根据 progress
更新进度条 UI)
编译器可以把它变成:
int local = 0;
while (working())
local++;
progress += local;
可能是正确的。但可能不是程序员所希望的:-(
委员会仍在努力解决这个问题。目前它 "works" 因为编译器没有太多优化原子。但这正在改变。
即使 progress
也是不稳定的,这仍然有效:
int local = 0;
while (working())
local++;
while (local--)
progress++;
:-/
在 x86 计算机只有一个 CPU 的那一天,使用一条指令确保中断不会拆分 read/modify/write 并且如果内存不会用作 DMA 缓冲区同样,它实际上是原子的(并且 C++ 没有在标准中提到线程,所以这没有得到解决)。
当客户桌面上很少有双处理器(例如双路 Pentium Pro)时,我有效地使用它来避免单核机器上的 LOCK 前缀并提高性能。
今天,它只会对所有设置为相同 CPU 关联的多个线程有所帮助,因此您担心的线程只会通过时间片到期和 运行 发挥作用同一CPU(核心)上的另一个线程。那是不现实的。
对于现代 x86/x64 处理器,单个指令被分解为多个 微操作 并且内存读写被缓冲。因此,不同 CPUs 上的不同线程 运行 不仅会将其视为非原子,而且可能会看到关于它从内存中读取的内容以及它假定其他线程已读取到该时间点的内容的不一致结果:您需要添加 内存栅栏 才能恢复理智的行为。
当您的编译器仅使用一条指令进行增量并且您的机器是单线程时,您的代码是安全的。 ^^