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


153

通常,for int numnum++(或++num)作为读取-修改-写入操作不是原子的。但是我经常看到编译器(例如GCC)为此生成以下代码(请尝试在此处):

在此处输入图片说明

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

如果是这样,是否意味着这样生成的代码num++可以在并发(多线程)场景中使用,而不会造成数据争用的危险(即,例如,我们不需要这样做,std::atomic<int>并且会产生相关的成本,因为它反正还是原子的?

更新

注意,这个问题不是增量是否原子的(不是,那过去是,现在是问题的开头)。这是在特定情况下是否可以使用,即在某些情况下是否可以利用单指令性质来避免lock前缀的开销。并且,正如公认的答案在有关单处理器机器的部分中提到的那样,以及该答案,其注释中的对话和其他解释都可以(尽管不是使用C或C ++)。


65
谁告诉你那add是原子的?
斯拉瓦

6
鉴于原子的功能之一是在优化过程中防止特定种类的重新排序,因此不考虑实际操作的原子性
jaggedSpire

19
我还要指出的是,如果这在您的平台上是原子的,则不能保证它将在另一个平台上使用。保持平台独立,并使用来表达您的意图std::atomic<int>
NathanOliver

8
在执行该add指令期间,另一个内核可以从该内核的缓存中窃取该内存地址并进行修改。在x86 CPU上,如果在add操作期间需要lock将地址锁定在缓存中,则该指令需要前缀。
David Schwartz

21
任何操作都可能是“原子的”。您所要做的就是幸运,永远不要执行任何能表明它不是原子的事情。原子仅作为保证是有价值的。假设你正在看汇编代码,问题是,特定的架构是否恰好为您提供保证编译器是否提供了保证,这是他们选择的装配水平执行。
Cort Ammon

Answers:


197

这绝对是C ++定义为导致未定义行为的数据竞赛,即使一个编译器碰巧产生的代码在某些目标机器上达到了您希望的效果。您需要std::atomic使用它来获得可靠的结果,但是memory_order_relaxed如果您不关心重新排序,可以将其用于。请参阅下面的示例代码和使用asm输出fetch_add


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

由于num ++是一条指令(add dword [num], 1),在这种情况下,我们可以得出num ++是原子的结论吗?

内存目标指令(纯存储除外)是在多个内部步骤中进行的读取,修改,写入操作。无需修改体系结构寄存器,但CPU在通过ALU发送数据时必须在内部保留数据。即使是最简单的CPU,实际的寄存器文件也只是数据存储区的一小部分,其锁存器将一级的输出保存为另一级的输入,依此类推。

其他CPU的内存操作可以在加载和存储之间全局可见。即,两个add dword [num], 1循环运行的线程会互相踩踏。(请参见@Margaret的答案以获取漂亮的图表)。从两个线程中的每个线程递增40k之后,在真正的多核x86硬件上,计数器可能只增加了约60k(而不是80k)。


“原子”,来自希腊语,意思是不可分割的,意味着没有观察者可以看到操作作为单独的步骤。同时对所有位进行物理/电气瞬时处理只是实现加载或存储操作的一种方法,但对于ALU操作而言,这甚至是不可能的。在x86上Atomicity的回答中,我对纯负载和纯存储进行了更详细的介绍,而该答案的重点是读-修改-写。

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

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

如果没有lock前缀,则另一个核心可以拥有缓存行的所有权,并在加载之后但在存储之前对其进行修改,以便其他存储在加载和存储之间成为全局可见的。其他几个答案也弄错了,并声称如果没有,lock您将获得同一高速缓存行的冲突副本。在具有相干缓存的系统中永远不会发生这种情况。

(如果locked指令在跨越两条高速缓存行的内存上进行操作,则需要花费更多的工作来确保对象的两个部分的更改在传播给所有观察者时都保持原子性,因此观察者看不到任何裂痕。CPU可能必须锁定整个内存总线,直到数据到达内存为止。不要使您的原子变量对齐!)

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


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


返回C ++:

num++不告知编译器您需要将其编译为单个read-modify-write实现的情况下使用完全是伪造的:

;; 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和更高版本的微体系结构那样,将复杂的指令解码为多个简单的微操作。有关更多信息,请参见Agner Fog的指令表/微体系结构指南,以及 标记Wiki以获得许多有用的链接(包括Intel的x86 ISA手册,这些手册可以PDF的形式免费获得)。


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

允许在编译时重新排序。使用std :: atomic获得的另一部分是对编译时重新排序的控制,以确保num++仅在执行某些其他操作之后您才能全局可见。

经典示例:将一些数据存储到缓冲区中以供另一个线程查看,然后设置一个标志。即使x86确实免费获取加载/发布存储,您仍然必须告诉编译器不要使用来重新排序flag.store(1, std::memory_order_release);

您可能期望此代码将与其他线程同步:

// flag is just a plain int global, not std::atomic<int>.
flag--;       // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo);    // doesn't look at flag, and the compilers 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 std::atomic<int> foo不是与std::atomic<int> foo,与@Richard Hodges讨论的相同)。

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

注意顺序一致性存储后,如何需要MFENCE(完全屏障)。一般而言,x86的订购严格,但允许StoreLoad重新订购。拥有存储缓冲区对于在流水线混乱的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>但是,由于没有非原子版本,因此使用来避免多余的原子操作并不总是那么容易(尽管这里的答案之一提供了一种shared_ptr_unsynchronized<T>为gcc 定义a的简便方法)。


回到num++; num-=2;编译状态num--:除非是,否则允许编译器执行此num操作volatile std::atomic<int>。如果可以重新排序,则按条件规则允许编译器在编译时确定它总是以这种方式发生。不能保证观察者可以看到中间值(num++结果)。

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

num++; num--之所以不能消失,是因为它仍然与其他查看的线程保持“与同步”关系num,并且它既是获取负载又是发布存储,因此不允许对该线程中其他操作进行重新排序。对于x86,这也许可以编译为MFENCE,而不是lock add dword [num], 0num += 0)。

PR0062中所讨论的,在编译时更主动地合并不相邻的原子操作可能是不好的(例如,进度计数器仅在末尾更新一次,而不是每次迭代都更新一次),但它也可以帮助性能降低(例如跳过shared_ptr如果编译器可以证明shared_ptr临时对象的整个生命周期中存在另一个对象,则ref的原子inc / dec计数在创建和销毁a的副本时进行。

num++; num--当一个线程立即解锁并重新锁定时,即使合并也会损害锁定实现的公平性。如果它实际上从未在asm中发布,那么即使是硬件仲裁机制也不会给另一个线程提供机会在这一点上获得锁定。


使用当前的gcc6.2和clang3.9,lock即使memory_order_relaxed在最明显的情况下,您仍然可以得到单独的ed操作。(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操作”- 在以后将更新值用于同一功能的情况下,它仍然更加高效。并且有一个免费的寄存器可供编译器存储在其中(当然,该变量未标记为volatile)。这意味着它是高度可能的是,编译器是否生成单个指令或多个用于操作取决于代码的功能的其余部分,而不只是有问题的单行。
Periata Breatta

@PeriataBreatta:是的,很好。在asm中,您可以使用mov eax, 1 xadd [num], eax(不带锁前缀)实现post-increment num++,但这不是编译器所做的。
彼得·科德斯

3
@ DavidC.Rankin:如果您要进行任何编辑,请放心。我不想做这个CW。仍然是我的工作(还有我的烂摊子:P)。在我的终极[飞盘]游戏之后,我会整理一下:)
彼得·科德斯

1
如果不是社区Wiki,则可能是相应标签Wiki上的链接。(x86和atomic标签?)。值得附加链接,而不是通过对SO的常规搜索获得希望的回报(如果我更好地知道在这方面应该适合什么地方,我会做的。我将不得不进一步研究“做与不做”的标签Wiki链接)
David C. Rankin

1
一如既往-很好的答案!连贯性和原子性之间有很好的区别(有些错误)
Leeor

39

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

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::atomics 更新的效果。

c ++标准具有“好像”规则,通过该规则,编译器可以对代码重新排序,甚至可以重写代码,前提是结果具有与仅执行您的代码完全相同的可观察到的效果(包括副作用)。码。

假设规则是保守的,特别是涉及原子的规则。

考虑:

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

5
这无法解释这add dword [rdi], 1不是原子(不带lock前缀)。负载是原子的,存储是原子的,但是没有什么可以阻止另一个线程修改负载和存储之间的数据。因此,商店可以采用其他线程进行的修改。参见jfdube.wordpress.com/2011/11/30/understanding-atomic-operations。而且, Jeff Preshing的无锁文章非常好,他在该介绍文章中确实提到了基本的RMW问题。
彼得·科德斯

3
真正发生的是,没有人在gcc中实现此优化,因为它几乎是无用的,而且可能比帮助有用更危险。(原则至少惊讶。也许有人正在期待一个暂时的状态,可见有时,并确定与统计probabilty。或者,他们正在使用硬件的观测点中断的修改。)无锁码需要被精心打造,因此不会有任何要优化的东西。查找并打印警告可能会很有用,以警告编码人员其代码可能并不代表他们的想法!
彼得·科德斯

2
这也许是编译器不执行此操作的原因(不出所料的原则等等)。观察到实际上在实际硬件上是可能的。但是,C ++内存排序规则并没有说明任何保证,一个线程的负载在C ++抽象机中与其他线程的操作“均匀”混合。我仍然认为这是合法的,但对程序员不利。
彼得·科德斯

2
思想实验:考虑在协作多任务系统上的C ++实现。它通过在需要的地方插入屈服点来避免死锁来实现std :: thread,但不是在每条指令之间。我猜您可能会认为C ++标准中的某些内容要求在num++和之间产生一个屈服点num--如果您可以在标准中找到需要的部分,它将解决此问题。 我敢肯定,它只要求没有观察者可以看到错误的重新排序,而无需在那产生收益。因此,我认为这只是实施质量问题。
彼得·科德斯

5
为了确定性,我在标准讨论邮件列表中询问。这个问题提出了两篇似乎都与Peter一致的论文,并解决了我对这种优化的担忧:wg21.link/p0062wg21.link/n4455 我感谢Andy提请我注意这些问题。
理查德·霍奇斯

38

没有很多复杂性,类似add DWORD PTR [rbp-4], 1CISC风格的指令。

它执行三个操作:从内存加载操作数,对其进行递增,然后将操作数存储回内存。
在这些操作期间,CPU两次获取并释放总线,在任何其他代理之间也可以获取总线,这违反了原子性。

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

X仅增加一次。


7
为了做到这一点,每个存储芯片都需要自己的算术逻辑单元(ALU)。实际上,这将要求每个存储芯片都是一个处理器。
理查德·霍奇斯

6
@LeoHeinsaar:内存目标指令是读-修改-写操作。无需修改体系结构寄存器,但是CPU在通过ALU发送数据时必须在内部保留数据。实际的寄存器文件只是即使是最简单的CPU内部数据存储的一小部分,其锁存器将一级的输出保存为另一级的输入,
依此类推

@PeterCordes您的评论正是我所寻找的答案。玛格丽特的回答使我怀疑这样的东西一定在里面。
Leo Heinsaar

将评论变成完整的答案,包括解决问题的C ++部分。
彼得·科德斯

1
@PeterCordes谢谢,非常详尽。显然这是一场数据竞赛,因此是C ++标准的未定义行为,我只是很好奇,如果生成的代码是我发布的内容,是否可以假设它是原子等,我还检查了至少英特尔开发人员手册非常清楚地定义了关于内存操作的原子性,而不是指令的不可分割性,正如我所假设的:“对于所有其他内存操作和所有外部可见事件,锁定操作是原子性的。”
Leo Heinsaar

11

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

IIRC add指令的原子变体称为lock xadd


3
lock xadd实现C ++ std :: atomic fetch_add,返回旧值。如果不需要,编译器将使用带lock前缀的普通内存目标指令。 lock addlock inc
彼得·科德斯

1
add [mem], 1在没有缓存的SMP机器上仍然不是原子的,请参阅我对其他答案的评论。
彼得·科德斯

请参阅我的答案以获取更多有关它不是原子的确切信息。我对这个相关问题的回答也结束了。
彼得·科德斯

10

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

根据“逆向工程”生成的程序集得出结论是危险的。例如,您似乎在禁用优化的情况下编译了代码,否则编译器将丢弃该变量或直接将其加载1而无需调用operator++。由于生成的程序集可能会基于优化标志,目标CPU等而发生重大变化,因此您的结论是基于沙子的。

同样,您认为一个汇编指令意味着一个操作是原子的也是错误的。add即使在x86体系结构上,这在多CP​​U系统上也不是原子的。


9

即使您的编译器始终将其作为原子操作发出,num根据C ++ 11和C ++ 14标准,并发访问任何其他线程仍将构成数据争用,并且该程序将具有未定义的行为。

但这比那更糟。首先,如上所述,编译器在增加变量时生成的指令可能取决于优化级别。其次,如果不是原子的,编译器可能会重新排序其他内存访问,例如++numnum

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前缀或内存隔离栅)的限制,以使每秒的操作最大化。

简而言之,线程安全编程的自然职责是:

  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的关键部分是更改语义以在任何时候都采用异步修改。通常让它成为UB是使编译器能够提升负载并使存储沉出循环的原因。
彼得·科德斯

9

在单核x86机器上,add相对于CPU 1上的其他代码,一条指令通常是原子的。中断无法将一条指令拆分到中间。

需要乱序执行以保持在单个内核中一次执行一个指令的错觉,因此在同一CPU上运行的任何指令将完全在添加之前或之后执行。

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

如果目标是一台小型嵌入式PC,并且没有计划将代码移至其他任何东西,则可以利用“添加”指令的原子性质。另一方面,操作本质上是原子的平台变得越来越稀缺。

(如果你是用C ++编写这不会帮助你,虽然,编译器不必需要一个选项num++编译到存储目的地添加或XADD 没有一个lock前缀。他们可以选择加载num到寄存器和存储使用单独的指令来增加结果,如果您使用结果,则可能会这样做。)


脚注1:lock由于I / O设备与CPU并发运行,因此即使在原始8086上也存在该前缀。lock add如果设备还可以修改值,或者相​​对于DMA访问,则单核系统上的驱动程序需要以原子方式递增设备内存中的值。


它甚至通常不是原子的:另一个线程可以同时更新同一变量,并且仅接管一个更新。
fuz

1
考虑多核系统。当然,在一个内核中,指令是原子的,但是对于整个系统而言,并不是原子的。
fuz 2016年

1
@FUZxxl:我的答案的第四和第五个字是什么?
超级猫

1
@supercat您的回答很容易引起误解,因为它仅考虑了当今单核的罕见情况,并给OP带来了错误的安全感。这就是为什么我也评论考虑多核情况的原因。
fuz

1
@FUZxxl:我进行了编辑,以消除可能引起混淆的读者,这些读者没有注意到这并不是在谈论普通的现代多核CPU。(还要更具体地说明一些超级猫不确定的东西)。顺便说一句,这个答案中的所有内容都已经属于我了,除了最后一句话说的是,其中“读取-修改-写入”是原子的“免费”平台的情况很少见。
彼得·科德斯

7

早在x86计算机只有一个CPU的那一天,使用一条指令就可以确保中断不会拆分读/修改/写操作,并且如果内存也不能用作DMA缓冲区,那实际上是原子的(并且C ++在标准中未提及线程,因此未解决)。

当在客户台式机上很少有双处理器(例如双插槽Pentium Pro)时,我有效地使用它来避免在单核计算机上使用LOCK前缀并提高性能。

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

在现代的x86 / x64处理器中,单个指令被分解为多个微操作,并且还对内存的读写进行了缓冲。因此,在不同CPU上运行的不同线程不仅会认为这是非原子的,而且可能会看到关于它从内存读取的内容以及假定其他线程已读取到该时间点的结果不一致的结果:您需要添加内存屏障以恢复正常状态行为。


1
中断还是不拆RMW操作,所以他们仍与信号同步处理一个线程在同一线程运行。当然,这仅在asm使用单个指令而不是单独加载/修改/存储时才有效。C ++ 11可以公开这种硬件功能,但不能公开(可能是因为它仅在Uniprocessor内核中用于与中断处理程序同步,而在用户空间中与信号处理程序同步才真正有用)。另外,体系结构没有读-修改-写内存目标指令。尽管如此,它仍可以像在非x86上像宽松的原子RMW一样进行编译
Peter Cordes

尽管我记得,直到超标量出现之前,使用Lock前缀并不昂贵。因此,即使该程序不需要它,也没有理由注意到它会拖慢486中的重要代码。
JDługosz

是的,对不起!我实际上并没有仔细阅读。我看到本段的开头是关于解码到uops的红色鲱鱼,并且还没读完就明白你在说什么。回复:486:我认为我已经读到最早的SMP是某种Compaq 386,但其内存排序语义与x86 ISA当前所说的不一样。当前的x86手册甚至可能提到SMP486。尽管如此,我认为它们甚至在HPC(Beowulf集群)中并不常见,直到PPro / Athlon XP出现。
彼得·科德斯

1
@PeterCordes好的。当然,假设也没有DMA /设备观察者-不适合在评论区域中也包括该评论者。感谢JDługosz的出色补充(答案和评论)。真的完成了讨论。
Leo Heinsaar

3
@Leo:一个没有提到的关键点:乱序的CPU会在内部对事物进行重新排序,但是黄金法则是,对于单个内核,它们保留了一次执行一个指令的错觉。(这包括触发上下文切换的中断)。值可能会无序地电存储到内存中,但是所有运行的单个内核会跟踪其自身所做的所有重新排序,以保持幻觉。这就是为什么不需要asm的内存障碍a = 1; b = a;即可正确加载刚存储的1的原因。
彼得·科德斯

4

。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

如果是这样,那么编译器可以自由地使唯一哪种方式使该程序可能的输出。即main()仅输出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++;

:-/


这个答案似乎只是在回答我和理查德正在思考的附带问题。我们最终解决了这个问题:事实证明,C ++标准确实允许在volatile不违反任何其他规则的情况下合并对非原子对象的操作。两个标准讨论文档正好讨论了这一点(Richard的评论中的链接),一个使用相同的进度计数器示例。因此,这是实现质量的问题,直到C ++标准化了阻止它的方法。
彼得·科德斯

是的,我的“不”实际上是对整个推理过程的答复。如果问题仅是“ num ++在某些编译器/实现中是否可以是原子的”,那么答案是肯定的。例如,编译器可以决定添加lock到每个操作。或某些编译器与单处理器组合,其中重新排序(即“好日子”)都没有原子性。但是,这有什么意义呢?您不能真正依靠它。除非您知道这就是您要编写的系统。(即使那样,更好的情况是atomic <int>在该系统上不添加任何额外的操作。因此,您仍然应该编写标准代码...)
tony

1
请注意,这And just remove the incr/decr entirely.不太正确。仍然是对的获取和释放操作num。在x86上,num++;num--可以仅编译为MFENCE,但绝对不能编译为MFENCE。(除非编译器的整个程序分析可以证明没有发生与num的修改同步的情况,并且之前的某些存储是否延迟到之后的加载之后也没有关系。)例如,是否是解锁并重新启动-lock-right-away用例,您仍然有两个单独的关键部分(也许使用mo_relaxed),而不是一个很大的部分。
彼得·科德斯

@PeterCordes啊,同意。
托尼

2

对,但是...

原子不是您要说的。您可能在问错事。

增量肯定是原子的。除非存储未对齐(并且因为您不对齐编译器,否则不会对齐),否则它必须在单个缓存行内对齐。缺少特殊的非缓存流指令,每次写入都通过缓存。完整的缓存行正在被原子地读取和写入,没有任何不同。
当然,小于缓存行的数据也是原子写入的(因为周围的缓存行是)。

它是线程安全的吗?

这是一个不同的问题,至少有两个充分的理由来回答肯定的“不!”。

首先,有可能另一个内核可能在L1中具有该高速缓存行的副本(通常共享L2和更高版本,但L1通常是每个内核!),并同时修改该值。当然,这也是原子发生的,但是现在您有了两个“正确的”(正确地,原子地,经过修改的)值-哪一个是现在真正正确的值?
当然,CPU会以某种方式对其进行分类。但是结果可能不是您所期望的。

其次,存在内存排序,或者在保证之前措辞不同。关于原子指令的最重要的事情不是这么多,他们是原子。正在订购。

您可以强制执行一项保证,即以某种有保证的,定义明确的顺序实现在内存方面发生的所有事情,其中​​您具有“先发生”保证。此排序可以是“宽松的”(完全没有)或您需要的严格。

例如,您可以设置指向某个数据块的指针(例如,某些计算的结果),然后自动释放 “数据已准备好”标志。现在,获取此标志的任何人都将被认为指针是有效的。实际上,它将始终是有效的指针,没有任何不同。这是因为对指针的写发生在原子操作之前。


2
加载和存储分别是原子的,但是整个读取-修改-写入操作作为一个整体绝对不是原子的。缓存是连贯的,因此永远不能保存同一行的冲突副本(en.wikipedia.org/wiki/MESI_protocol)。当另一个核心处于“已修改”状态时,该核心甚至无法拥有只读副本。使它成为非原子的原因是,执行RMW的核心可能会失去对负载和存储之间的缓存行的所有权。
彼得·科德斯

2
另外,不,整个缓存行并不总是原子传输。看到这个答案,在实验上证明了多插槽Opteron通过使用超传输将8B块中的缓存行转移,从而使16B SSE能够以非原子的方式存储非原子存储,即使它们对于相同类型的单插槽CPU 原子的(因为负载/存储硬件具有通往L1缓存的16B路径)。x86仅保证单独负载的原子性或最多存储8B。
彼得·科德斯

将对齐方式留给编译器并不意味着内存将在4字节边界上对齐。编译器可以具有选项或编译指示来更改对齐边界。例如,这对于处理网络流中紧密打包的数据很有用。
德米特里·鲁巴诺维奇

2
先进的,别无所求。如示例所示,不具有结构的一部分的具有自动存储的整数将绝对正确地对齐。声称有什么不同完全是愚蠢的。缓存行以及所有POD的大小和对齐方式都是PoT(2的幂)-在世界上任何非虚幻的体系结构上。数学认为,任何正确对齐的PoT都可以恰好适合(永远不会更多)任何其他相同或更大大小的PoT。因此,我的说法是正确的。
戴蒙

1
@Damon,问题中给出的示例未提及结构,但并没有将问题缩小到整数不是结构一部分的情况。POD绝对可以具有PoT大小,而不能与PoT对齐。请查看以下答案以获取语法示例:stackoverflow.com/a/11772340/1219722。因此,这几乎不是“技巧”,因为以这种方式声明的POD在实际代码中已在网络代码中大量使用。
德米特里·鲁巴诺维奇

2

一个单一的编译器的输出,在一个特定的CPU架构,禁用(因为GCC甚至不编译优化++add优化性能时以快速和肮脏的例子),似乎暗示着增加此方法是原子并不意味着这是符合标准的(试图访问时会导致不确定的行为num在一个线程),并且是错误的反正,因为add不是在x86的原子。

请注意,原子(使用lock指令前缀)在x86上相对较重(请参阅此相关答案),但仍显着小于互斥体,这在此用例中不太合适。

使用clang ++ 3.8编译时,获得以下结果-Os

通过引用,以“常规”方式递增一个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


-3

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

原因num++ 似乎是原子性的,因为在x86机器上,递增32位整数实际上是原子性的(假定未进行任何内存检索)。但这不是c ++标准不能保证的,也不是在不使用x86指令集的计算机上的情况。因此,此代码在竞争条件下不是跨平台安全的。

您也不能完全保证即使在x86体系结构上,此代码也不受竞争条件的影响,因为除非明确指示,否则x86不会设置加载并将其存储到内存中。因此,如果多个线程尝试同时更新此变量,则它们可能最终会增加缓存(过时)的值

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


“是因为在x86机器上,递增32位整数实际上是原子的。” 您可以提供证明它的文档的链接吗?
斯拉瓦(Slava)2013年

8
在x86上也不是原子的。它是单核安全的,但是如果有多个核(并且有),那根本不是原子的。
哈罗德

x86 add实际上可以保证是原子的吗?如果寄存器增量是原子的,我不会感到惊讶,但这几乎没有用。为了使寄存器增量对另一个线程可见,该线程需要在内存中,这将需要其他指令来加载和存储它,从而消除原子性。我的理解是这就是为什么lock存在指令前缀的原因。唯一有用的原子add适用于已取消引用的内存,并使用lock前缀确保操作期间缓存行被锁定
ShadowRanger

@Slava @Harold @ShadowRanger我更新了答案。add是原子的,但我明确表示,这并不意味着代码在竞争条件下是安全的,因为更改不会立即在全局上可见。
Xirema

3
@Xirema从定义上使它“不是原子的”
harold
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.