一般来说,对于 int num
,num++
(或 ++num
)作为读-修改-写操作,不是原子的。但我经常看到编译器(例如 GCC)为其生成以下代码(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 是否增加 原子(它不是,那是问题的开头)。它是否可以在特定场景中,即是否可以在某些情况下利用单指令性质来避免lock
前缀的开销。而且,正如接受的答案在关于单处理器机器的部分以及 this answer 中提到的那样,其评论中的对话和其他人解释说,它可以(尽管不能使用 C 或 C++)。
add
是原子的?
std::atomic<int>
表达您的意图。
add
指令期间,另一个内核可以从该内核的缓存中窃取该内存地址并对其进行修改。在 x86 CPU 上,如果在操作期间需要将地址锁定在高速缓存中,add
指令需要一个 lock
前缀。
这绝对是 C++ 定义为导致未定义行为的数据竞赛,即使一个编译器碰巧生成了在某些目标机器上执行您希望的代码。您需要使用 std::atomic
以获得可靠的结果,但如果您不关心重新排序,则可以将其与 memory_order_relaxed
一起使用。有关使用 fetch_add
的一些示例代码和 asm 输出,请参见下文。
但首先,问题的汇编语言部分:
由于 num++ 是一条指令(add dword [num], 1),我们可以得出结论 num++ 在这种情况下是原子的吗?
内存目标指令(纯存储除外)是在多个内部步骤中发生的读-修改-写操作。没有修改架构寄存器,但 CPU 在通过其 ALU 发送数据时必须在内部保存数据。即使是最简单的 CPU,实际的寄存器文件也只是数据存储的一小部分,锁存器将一个阶段的输出作为另一阶段的输入,等等。
来自其他 CPU 的内存操作可以在加载和存储之间变得全局可见。即在循环中运行add dword [num], 1
的两个线程会踩到彼此的存储。 (请参阅 @Margaret's answer 以获得漂亮的图表)。在两个线程中的每一个增加 40k 之后,在真正的多核 x86 硬件上,计数器可能只增加了约 60k(不是 80k)。
“原子”,来自希腊语,意思是不可分割的,意味着没有观察者可以看到操作作为单独的步骤。对所有位同时在物理/电气上同时发生只是为加载或存储实现这一目标的一种方法,但对于 ALU 操作来说这甚至是不可能的。 我在我对 Atomicity on x86 的回答,而这个回答侧重于读取-修改-写入。
lock
prefix 可以应用于许多读取-修改-写入(内存目标)指令,以使整个操作相对于系统中所有可能的观察者(其他内核和 DMA 设备,而不是连接到 CPU 引脚的示波器)具有原子性)。这就是它存在的原因。 (另见this Q&A)。
所以lock add dword [num], 1
是原子的。运行该指令的 CPU 内核将在其私有 L1 高速缓存中将高速缓存行固定在已修改状态,从负载从高速缓存读取数据直到存储将其结果提交回高速缓存。根据 MESI cache coherency protocol(或多核 AMD/Intel 使用的 MOESI/MESIF 版本)的规则,这可以防止系统中的任何其他缓存在从加载到存储的任何时间点都拥有缓存行的副本CPU,分别)。因此,其他核心的操作似乎发生在之前或之后,而不是期间。
如果没有 lock
前缀,另一个核心可以获取缓存行的所有权,并在我们加载之后但在我们的存储之前对其进行修改,以便其他存储在我们的加载和存储之间变得全局可见。其他几个答案会出错,并声称如果没有 lock
,您将获得同一缓存行的冲突副本。这在具有一致缓存的系统中永远不会发生。
(如果 lock
ed 指令在跨越两个缓存行的内存上运行,则需要做更多的工作才能确保对象两部分的更改在传播到所有观察者时保持原子性,因此观察者不会看到撕裂。 CPU 可能必须锁定整个内存总线,直到数据到达内存。不要错位你的原子变量!)
请注意,lock
前缀还将指令变成完整的内存屏障(如 MFENCE),停止所有运行时重新排序,从而提供顺序一致性。 (请参阅 Jeff Preshing's excellent blog post。他的其他帖子也都非常出色,并且清楚地解释了很多关于 lock-free programming 的好东西,从 x86 和其他硬件细节到 C++ 规则。)
在单处理器机器上,或在单线程进程中,单个 RMW 指令实际上是原子的,没有 lock
前缀。其他代码访问共享变量的唯一方法是 CPU 进行上下文切换,这不能在指令中间发生。因此,普通的 dec dword [num]
可以在单线程程序及其信号处理程序之间同步,或者在单核机器上运行的多线程程序中同步。请参阅 the second half of my answer on another question 及其下方的评论,我将在其中更详细地解释这一点。
回到 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
will actually emit this,因为 (Pentium) P5 的超标量管道没有像 P6 和更高版本的微架构那样将复杂指令解码为多个简单的微操作。有关更多信息,请参阅 Agner Fog's instruction tables / microarchitecture guide,以及许多有用链接的 x86 标签 wiki(包括 Intel 的 x86 ISA 手册,它们是以 PDF 格式免费提供))。
不要将目标内存模型 (x86) 与 C++ 内存模型混淆
允许使用Compile-time reordering。使用 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
类似地异步修改,但它的作用远不止于此. (实际上有 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 上格式良好的源代码 + 汇编语言代码。您可以选择其他目标体系结构,包括 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--;
指令中的评论中的讨论:
关于同一主题的单独问答:Why don't compilers merge redundant std::atomic writes?,我的回答重申了我在下面写的很多内容。
当前的编译器实际上并没有这样做(还),但不是因为不允许这样做。 C++ WG21/P0062R1: When should compilers optimize atomics? 讨论了许多程序员对编译器不会进行“令人惊讶的”优化的期望,以及标准可以为程序员提供控制权的方法。 N4455 讨论了许多可以优化的例子,包括这个。它指出内联和常量传播可以引入像 fetch_or(0)
这样的东西,它可能会变成一个 load()
(但仍然具有获取和释放语义),即使原始源没有任何明显的冗余原子操作。
编译器(还)不这样做的真正原因是:(1) 没有人编写允许编译器安全地执行此操作的复杂代码(不会出错),以及 (2) 它可能违反 principle of least surprise .无锁代码一开始就很难正确编写。所以不要随意使用原子武器:它们并不便宜,也没有进行太多优化。但是,使用 std::shared_ptr<T>
避免冗余原子操作并不总是那么容易,因为它没有非原子版本(尽管 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]
而不是 { 2} / lock sub dword [num], 2
。
num++; num--
不能消失,因为它仍然与查看 num
的其他线程具有 Synchronizes With 关系,并且它既是获取加载又是释放存储,不允许重新排序此线程中的其他操作。对于 x86,这可能能够编译为 MFENCE,而不是 lock add dword [num], 0
(即 num += 0
)。
正如 PR0062 中所讨论的,在编译时更积极地合并不相邻的原子操作可能会很糟糕(例如,进度计数器仅在结束时更新一次,而不是每次迭代),但它也可以在没有缺点的情况下提高性能(例如如果编译器可以证明另一个 shared_ptr
对象在临时对象的整个生命周期内存在,则在创建和销毁 shared_ptr
的副本时跳过 ref 的原子 inc / dec 计数。)
当一个线程立即解锁并重新锁定时,即使是 num++; num--
合并也可能会损害锁定实现的公平性。如果它从未真正在 asm 中释放,即使硬件仲裁机制也不会给另一个线程在此时获取锁的机会。
使用当前的 gcc6.2 和 clang3.9,即使在最明显可优化的情况下使用 memory_order_relaxed
,您仍然可以获得单独的 lock
操作。 (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
没有太多复杂性,像 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 只增加一次。
...现在让我们启用优化:
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
更新的效果。
c++ 标准具有“好像”规则,允许编译器重新排序代码,甚至重写代码,只要结果具有完全相同的可观察效果(包括副作用),就好像它只是执行了你的代码。
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 [rdi], 1
是 not 原子的(没有 lock
前缀)。加载是原子的,存储也是原子的,但没有什么能阻止另一个线程修改加载和存储之间的数据。所以商店可以踩到另一个线程所做的修改。请参阅jfdube.wordpress.com/2011/11/30/understanding-atomic-operations。另外,Jeff Preshing's lock-free articles are extremely good,他确实在那篇介绍文章中提到了基本的 RMW 问题。
std::atomic<int>&
,我认为编译器可以自由地将所有这些操作合并为一个。
add 指令不是原子的。它引用内存,并且两个处理器内核可能具有该内存的不同本地缓存。
IIRC add 指令的原子变体称为 lock xadd
lock xadd
实现 C++ std::atomic fetch_add
,返回旧值。如果您不需要,编译器将使用带有 lock
前缀的普通内存目标指令。 lock add
或 lock inc
。
add [mem], 1
在没有缓存的 SMP 机器上仍然不是原子的,请参阅我对其他答案的评论。
volatile
类似于具有 memory_order_seq_cst 的 C++ std::atomic<>
)。有关读取 same 值的两个核心如何导致问题,请参阅 Margaret 的回答。
由于对应于 num++ 的第 5 行是一条指令,在这种情况下,我们可以得出结论 num++ 是原子的吗?
根据“逆向工程”生成的装配得出结论是危险的。例如,您似乎在禁用优化的情况下编译了代码,否则编译器会丢弃该变量或直接将 1 加载到它而不调用 operator++
。因为生成的程序集可能会发生显着变化,基于优化标志、目标 CPU 等,您的结论是基于沙子的。
此外,您认为一条汇编指令意味着操作是原子的想法也是错误的。此 add
在多 CPU 系统上不是原子的,即使在 x86 架构上也是如此。
即使您的编译器始终将此作为原子操作发出,根据 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
是“原子的”,并且编译器会根据需要生成检查循环(正如我所说,它是 UB,因此编译器可以自由删除它,用无限循环替换它,等等。 ),编译器可能仍然会移动指针赋值,或者更糟糕的是 vector
的初始化到增量操作后的某个点,从而导致新线程中的混乱。在实践中,如果优化编译器完全删除 ready
变量和检查循环,我一点也不感到惊讶,因为这不会影响语言规则下的可观察行为(与您个人的希望相反)。
事实上,在去年的 Meeting C++ 会议上,我从两位编译器开发人员那里听说,只要语言规则允许,即使看到很小的性能改进,他们也很乐意实现使天真的编写的多线程程序行为不端的优化在正确编写的程序中。
最后,即使如果您不关心可移植性,并且您的编译器非常好,您使用的 CPU 很可能是超标量 CISC 类型,并且会将指令分解为微操作,重新排序和/或推测性地执行它们,在一定程度上仅通过同步原语(例如(在 Intel 上)LOCK
前缀或内存栅栏)来限制,以便最大化每秒操作数。
长话短说,线程安全编程的自然职责是:
你的职责是编写在语言规则(尤其是语言标准内存模型)下具有明确行为的代码。您的编译器的职责是在目标架构的内存模型下生成具有相同定义(可观察)行为的机器代码。您的 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 和 check 是顺序一致的,这是 C++ 内存模型中描述的另一个术语,它禁止这种特定的重新排序。因此,编译器不得重新排序指令,并且还必须告诉 CPU,它不得将写入 vec 的操作推迟到准备就绪的增量之后。顺序一致是语言标准中关于原子的最强保证。例如通过 std::atomic
ready
的所有用法,它可能会将 while (!ready);
编译成更类似于 if(!ready) { while(true); }
的东西。赞成:std::atomic 的一个关键部分是改变语义以在任何时候进行异步修改。让它通常是 UB 是允许编译器提升负载和接收存储脱离循环的原因。
在单核 x86 机器上,相对于 CPU1 上的其他代码,add
指令通常是原子指令。中断不能将一条指令从中间拆分。
需要乱序执行来保持指令在单个内核中一次按顺序执行的错觉,因此在同一个 CPU 上运行的任何指令要么完全在添加之前发生,要么完全在添加之后发生。
现代 x86 系统是多核的,因此单处理器的特殊情况不适用。
如果目标是小型嵌入式 PC 并且没有计划将代码移动到其他任何地方,则可以利用“add”指令的原子性质。另一方面,操作本质上是原子的平台变得越来越稀缺。
(不过,如果您使用 C++ 编写,这对您没有帮助。编译器没有选项要求 num++
编译到内存目标 add 或 xadd 没有 {2 } 前缀。他们可以选择将 num
加载到寄存器中并使用单独的指令存储增量结果,如果您使用结果,可能会这样做。)
脚注 1:即使在原始 8086 上也存在 lock
前缀,因为 I/O 设备与 CPU 并行运行;如果设备也可以修改它,或者关于 DMA 访问,单核系统上的驱动程序需要 lock add
以原子方式递增设备内存中的值。
早在 x86 计算机只有一个 CPU 的那一天,使用单个指令可确保中断不会拆分读取/修改/写入,如果内存也不会用作 DMA 缓冲区,它实际上是原子的(并且C++ 没有在标准中提到线程,所以没有解决这个问题)。
当客户台式机上很少有双处理器(例如双插槽 Pentium Pro)时,我有效地使用它来避免单核机器上的 LOCK 前缀并提高性能。
今天,它只有助于对抗所有设置为相同 CPU 亲和性的多个线程,因此您担心的线程只会通过时间片到期和在同一 CPU(核心)上运行另一个线程来发挥作用。那是不现实的。
使用现代 x86/x64 处理器,单条指令被分解为几个微操作,并且内存读取和写入被缓冲。因此,在不同 CPU 上运行的不同线程不仅会将此视为非原子的,而且可能会看到关于它从内存中读取的内容以及假设其他线程已读取到该时间点的内容不一致的结果:您需要添加内存围栏以恢复理智行为。
a = 1; b = a;
的 asm 设置内存屏障来正确加载您刚刚存储的 1。
否。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 的 main()。
这就是“好像”规则。
无论输出如何,您都可以以相同的方式考虑线程同步 - 如果线程 A 执行 num++; num--;
并且线程 B 重复读取 num
,那么可能的有效交错是线程 B 永远不会在 num++
和 num--
之间读取.由于该交错是有效的,编译器可以自由地进行 only 可能的交错。并且完全删除 incr/decr。
这里有一些有趣的含义:
while (working())
progress++; // atomic, global
(即想象一些其他线程基于 progress
更新进度条 UI)
编译器可以把它变成:
int local = 0;
while (working())
local++;
progress += local;
可能这是有效的。但可能不是程序员所希望的:-(
委员会仍在研究这些东西。目前它“有效”,因为编译器不会对原子进行太多优化。但这种情况正在改变。
即使 progress
也是易变的,这仍然有效:
int local = 0;
while (working())
local++;
while (local--)
progress++;
:-/
volatile
原子对象的操作。两份标准讨论文档准确地讨论了这一点(Richard's comment 中的链接),一份使用相同的进度计数器示例。因此,在 C++ 标准化防止它的方法之前,这是一个实现质量问题。
lock
添加到每个操作。或者一些编译器+单处理器组合,其中没有重新排序(即“好日子”)一切都是原子的。但那有什么意义呢?你不能真的依赖它。除非你知道那是你正在编写的系统。 (即便如此,最好是 atomic<int> 在该系统上不添加额外的操作。所以你仍然应该编写标准代码......)
And just remove the incr/decr entirely.
并不完全正确。它仍然是 num
上的获取和释放操作。在 x86 上,num++;num--
可以编译为 MFENCE,但绝对不是什么都没有。 (除非编译器的整个程序分析可以证明没有任何东西与 num 的修改同步,并且之前的某些存储是否延迟到之后的加载之后也没关系。)例如,如果这是一个解锁和重新-lock-right-away 用例,您仍然有两个单独的关键部分(可能使用 mo_relaxed),而不是一个大的。
对,但是...
原子不是你想说的。你可能问错了。
增量肯定是原子的。除非存储未对齐(并且由于您向编译器左对齐,它不是),否则它必须在单个缓存行内对齐。缺少特殊的非缓存流指令,每次写入都会通过缓存。完整的高速缓存行被原子读取和写入,没有什么不同。当然,小于缓存线的数据也是原子写入的(因为周围的缓存线是)。
它是线程安全的吗?
这是一个不同的问题,至少有两个很好的理由可以用明确的“不!”来回答。
首先,有可能另一个内核可能在 L1 中拥有该缓存行的副本(L2 及以上通常是共享的,但 L1 通常是每个内核的!),并同时修改该值。当然,这也是原子发生的,但现在你有两个“正确”(正确、原子、修改)值——现在哪一个是真正正确的?当然,CPU 会以某种方式解决它。但结果可能不是你所期望的。
其次,有内存排序,或者措辞不同的发生前保证。原子指令最重要的不是它们是原子的。是下单了
您有可能强制保证所有发生在内存方面的事情都以某种有保证的、明确定义的顺序实现,其中您有“之前发生”的保证。这种排序可能是“宽松的”(读作:根本没有)或您需要的严格。
例如,您可以设置一个指向某个数据块的指针(例如,某个计算的结果),然后自动释放“数据准备就绪”标志。现在,任何获得此标志的人都会被引导认为指针是有效的。事实上,它总是一个有效的指针,从来没有什么不同。那是因为对指针的写入发生在原子操作之前。
单个编译器的输出,在特定的 CPU 架构上,禁用了优化(因为 gcc 在优化 in a quick&dirty example 时甚至不会将 ++
编译为 add
),似乎意味着以这种方式递增是原子的并不意味着这一点符合标准(尝试在线程中访问 num
时会导致未定义的行为),并且无论如何都是错误的,因为 add
在 x86 中 不是 原子的。
请注意,原子(使用 lock
指令前缀)在 x86 (see this relevant answer) 上相对较重,但仍然明显少于互斥体,这在此用例中不太合适。
以下结果来自使用 -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;
}
这个例子并不比常规方法复杂多少,只是将 lock
前缀添加到 incl
指令中 - 但请注意,如前所述,这不便宜。仅仅因为组装看起来很短并不意味着它很快。
inc(std::atomic<int>&):
lock incl (%rdi)
retq
当您的编译器仅使用一条指令进行增量并且您的机器是单线程时,您的代码是安全的。 ^^
尝试在非 x86 机器上编译相同的代码,您会很快看到非常不同的汇编结果。
num++
出现 是原子的原因是因为在 x86 机器上,增加一个 32 位整数实际上是原子的(假设没有发生内存检索)。但这既不受 c++ 标准的保证,也不可能在不使用 x86 指令集的机器上出现。所以这段代码在竞争条件下不是跨平台安全的。
即使在 x86 架构上,您也不能有力地保证此代码不受竞争条件的影响,因为 x86 不会将加载和存储设置到内存,除非特别指示这样做。因此,如果多个线程试图同时更新这个变量,它们最终可能会增加缓存(过时)的值
那么,我们有 std::atomic<int>
等的原因是,当您使用无法保证基本计算的原子性的架构时,您有一种机制将强制编译器生成原子代码。
add
真的保证是原子的吗?如果寄存器增量是原子的,我不会感到惊讶,但这几乎没有用;为了使寄存器增量对另一个线程可见,它需要在内存中,这需要额外的指令来加载和存储它,从而消除原子性。我的理解是,这就是为什么存在 lock
前缀的原因; the only useful atomic add
applies to dereferenced memory, and uses the lock
prefix to ensure the cache line is locked for the duration of the operation。
add
是原子的,但我明确表示这并不意味着代码是竞争条件安全的,因为更改不会立即全局可见。