ChatGPT解决这个技术问题 Extra ChatGPT

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

一般来说,对于 int numnum++(或 ++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 是原子的?
鉴于原子的特性之一是在优化期间防止特定类型的重新排序,不,不管实际操作的原子性如何
我还想指出,if 这在您的平台上是原子的,不能保证它会在另一个 pltaform 上。独立于平台并使用 std::atomic<int> 表达您的意图。
在执行该 add 指令期间,另一个内核可以从该内核的缓存中窃取该内存地址并对其进行修改。在 x86 CPU 上,如果在操作期间需要将地址锁定在高速缓存中,add 指令需要一个 lock 前缀。
任何操作都可能碰巧是“原子的”。您所要做的就是幸运,并且永远不会执行任何会表明它不是原子的事情。 Atomic 仅作为保证有价值。鉴于您正在查看汇编代码,问题是该特定架构是否恰好为您提供保证,以及编译器是否提供他们选择的汇编级别实现的保证。

P
Peter Cordes

这绝对是 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,您将获得同一缓存行的冲突副本。这在具有一致缓存的系统中永远不会发生。

(如果 locked 指令在跨越两个缓存行的内存上运行,则需要做更多的工作才能确保对象两部分的更改在传播到所有观察者时保持原子性,因此观察者不会看到撕裂。 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,以及许多有用链接的 标签 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-- 一样编译:编译器被允许这样做,除非 numvolatile 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

“[使用单独的指令]过去效率更高......但现代 x86 CPU 再次处理 RMW 操作至少同样有效”——在稍后将在同一函数中使用更新值的情况下,它仍然更有效并且有一个免费寄存器可供编译器将其存储在其中(当然,该变量未标记为易失性)。这意味着编译器是否为操作生成单条指令或多条指令很可能取决于函数中的其余代码,而不仅仅是有问题的单行。
@DavidC.Rankin:如果您有任何想要进行的编辑,请随意。不过,我不想制作这个 CW。这仍然是我的工作(和我的烂摊子:P)。我会在我的终极 [飞盘] 比赛后整理一些 :)
如果不是社区 wiki,那么可能是适当标签 wiki 上的链接。 (x86 和 atomic 标签?)。值得额外的链接,而不是通过对 SO 进行通用搜索来获得有希望的回报(如果我更清楚它应该适合这方面的位置,我会这样做。我将不得不进一步深入研究标签维基链接)
一如既往 - 很好的答案!一致性和原子性之间的良好区别(其他人弄错了)
@МаксФедотов:好的,很有趣,感谢您找到准确的措辞。我想这是一种可能的硬件设计,但我不确定真正的 CPU 是这样设计的。在我看来,这听起来像是 OoO 早期负载执行者的另一种心智模型。我无法肯定地说;如果还没有重复,可能会自己提出一个有趣的 SO 问题。如果您想提出这样的问题,请随时在此处引用或解释我的任何评论。我从未见过 CPU 供应商宣传具有更大“无效队列”以获得更好的内存并行性的新版本,仅加载缓冲区。
M
Margaret Bloom

没有太多复杂性,像 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 只增加一次。


@LeoHeinsaar为了做到这一点,每个内存芯片都需要自己的算术逻辑单元(ALU)。实际上,它要求每个存储芯片都是一个处理器。
@LeoHeinsaar:内存目标指令是读-修改-写操作。没有修改架构寄存器,但 CPU 在通过其 ALU 发送数据时必须在内部保存数据。即使是最简单的 CPU,实际的寄存器文件也只是数据存储的一小部分,锁存器将一个阶段的输出作为另一阶段的输入,等等。
@PeterCordes您的评论正是我正在寻找的答案。玛格丽特的回答让我怀疑里面一定会发生这样的事情。
将该评论变成完整的答案,包括解决问题的 C++ 部分。
@PeterCordes 谢谢,非常详细并且在所有方面。这显然是一场数据竞赛,因此 C++ 标准未定义行为,我只是好奇在生成的代码是我发布的代码的情况下,是否可以假设这可能是原子等。我还检查了至少英特尔开发人员手册非常清楚地定义了关于内存操作的原子性,而不是指令不可分割性,正如我所假设的那样:“锁定操作对于所有其他内存操作和所有外部可见事件都是原子的。”
C
Community

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

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++ 内存模型中,不可能有另一个线程观察增量的结果。如果 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 [rdi], 1not 原子的(没有 lock 前缀)。加载是原子的,存储也是原子的,但没有什么能阻止另一个线程修改加载和存储之间的数据。所以商店可以踩到另一个线程所做的修改。请参阅jfdube.wordpress.com/2011/11/30/understanding-atomic-operations。另外,Jeff Preshing's lock-free articles are extremely good,他确实在那篇介绍文章中提到了基本的 RMW 问题。
“另一个观察线程(甚至忽略缓存同步延迟)没有机会观察个别变化” - 这实际上是一个问题吗?即使使用 std::atomic<int>&,我认为编译器可以自由地将所有这些操作合并为一个。
这里真正发生的是,没有人在 gcc 中实现这种优化,因为它几乎没有用,而且可能比有用更危险。 (最不意外的原则。也许有人期望临时状态有时是可见的,并且对统计概率没问题。或者他们正在使用硬件观察点来中断修改。)无锁代码需要精心设计,所以不会有什么可以优化的。查找它并打印警告可能很有用,以提醒编码人员他们的代码可能与他们的想法不同!
这可能是编译器不实现这一点的原因(最少意外原则等)。在实际硬件上观察到这一点是可能的。但是,C++ 内存排序规则并没有说明任何保证一个线程的负载与 C++ 抽象机中其他线程的操作“均匀”混合。我仍然认为这将是合法的,但程序员是敌对的。
为了最终确定,我在标准讨论邮件列表中询问。这个问题出现了 2 篇似乎都同意 Peter 的论文,并解决了我对此类优化的担忧:wg21.link/p0062wg21.link/n4455 感谢 Andy 让我注意到这些问题。
S
Sven Nilsson

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

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


lock xadd 实现 C++ std::atomic fetch_add,返回旧值。如果您不需要,编译器将使用带有 lock 前缀的普通内存目标指令。 lock addlock inc
add [mem], 1 在没有缓存的 SMP 机器上仍然不是原子的,请参阅我对其他答案的评论。
有关它如何不是原子的更多详细信息,请参阅我的答案。我的回答on this related question也结束了。
另外,更根本的是,不,对于同一内存,cache 中的两个内核不能有不同的值caches are coherent。请不要散布有关 CPU 工作原理的错误信息。另请参见 Myths Programmers Believe about CPU Caches(Java volatile 类似于具有 memory_order_seq_cst 的 C++ std::atomic<>)。有关读取 same 值的两个核心如何导致问题,请参阅 Margaret 的回答。
C
Cody Gray

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

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

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


A
Arne Vogel

即使您的编译器始终将此作为原子操作发出,根据 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 是允许编译器提升负载和接收存储脱离循环的原因。
P
Peter Cordes

在单核 x86 机器上,相对于 CPU1 上的其他代码,add 指令通常是原子指令。中断不能将一条指令从中间拆分。

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

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

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

(不过,如果您使用 C++ 编写,这对您没有帮助。编译器没有选项要求 num++ 编译到内存目标 add 或 xadd 没有 {2 } 前缀。他们可以选择将 num 加载到寄存器中并使用单独的指令存储增量结果,如果您使用结果,可能会这样做。)

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


它甚至通常都不是原子的:另一个线程可以同时更新同一个变量,并且只接管一个更新。
考虑一个多核系统。当然,在一个内核中,指令是原子的,但就整个系统而言它不是原子的。
@FUZxxl:我回答的第四个和第五个词是什么?
@supercat您的回答非常具有误导性,因为它仅考虑了当今罕见的单核情况,并给OP带来了错误的安全感。这就是为什么我也评论说要考虑多核案例。
@FUZxxl:我做了一个编辑,为那些没有注意到这不是在谈论普通的现代多核 CPU 的读者消除潜在的困惑。 (并且还要更具体地说明一些 supercat 不确定的东西)。顺便说一句,这个答案中的所有内容都已经在我的里面了,除了最后一句关于 read-modify-write 是原子“免费”的平台是如何罕见的。
P
Peter Cordes

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

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

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

使用现代 x86/x64 处理器,单条指令被分解为几个微操作,并且内存读取和写入被缓冲。因此,在不同 CPU 上运行的不同线程不仅会将此视为非原子的,而且可能会看到关于它从内存中读取的内容以及假设其他线程已读取到该时间点的内容不一致的结果:您需要添加内存围栏以恢复理智行为。


中断仍然不会拆分 RMW 操作,因此它们仍然将单个线程与在同一线程中运行的信号处理程序同步。当然,这只适用于 asm 使用单个指令,而不是单独的加载/修改/存储。 C++11 可以公开这种硬件功能,但它没有(可能是因为它只在单处理器内核中与中断处理程序同步才真正有用,而不是在用户空间与信号处理程序同步)。此外,架构没有读取-修改-写入内存目标指令。尽管如此,它仍然可以像在非 x86 上轻松的原子 RMW 一样编译
尽管我记得,在超级缩放器出现之前,使用 Lock 前缀并不是非常昂贵。因此,没有理由注意到它会减慢 486 中的重要代码,即使该程序不需要它。
是的对不起!其实我没有仔细阅读。我看到段落的开头是关于解码到 uops 的红鲱鱼,并没有读完以了解您实际所说的内容。 re: 486: 我想我读过最早的 SMP 是某种 Compaq 386,但它的内存排序语义与 x86 ISA 目前所说的不同。当前的 x86 手册甚至可能会提到 SMP 486。不过,我认为,在 PPro / Athlon XP 时代之前,它们甚至在 HPC(Beowulf 集群)中也不常见。
@PeterCordes 好的。当然,假设也没有 DMA/设备观察者 - 也不适合在评论区域中包含该观察者。感谢 JDługosz 的出色补充(回答和评论)。真正完成了讨论。
@Leo:没有提到的一个关键点:乱序 CPU 会在内部重新排序,但黄金法则是 对于单核,它们会保留运行一个指令的错觉一次,按顺序。 (这包括触发上下文切换的中断)。值可能会乱序存储到内存中,但运行一切的单核会跟踪它自己所做的所有重新排序,以保持错觉。这就是为什么您不需要为等效于 a = 1; b = a; 的 asm 设置内存屏障来正确加载您刚刚存储的 1。
t
tony

否。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++;

:-/


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

对,但是...

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

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

它是线程安全的吗?

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

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

其次,有内存排序,或者措辞不同的发生前保证。原子指令最重要的不是它们是原子的。是下单了

您有可能强制保证所有发生在内存方面的事情都以某种有保证的、明确定义的顺序实现,其中您有“之前发生”的保证。这种排序可能是“宽松的”(读作:根本没有)或您需要的严格。

例如,您可以设置一个指向某个数据块的指针(例如,某个计算的结果),然后自动释放“数据准备就绪”标志。现在,任何获得此标志的人都会被引导认为指针是有效的。事实上,它总是一个有效的指针,从来没有什么不同。那是因为对指针的写入发生在原子操作之前。


load 和 store 各自是原子的,但是整个 read-modify-write 操作作为一个整体绝对是不是原子的。缓存是连贯的,因此永远不能保存同一行 (en.wikipedia.org/wiki/MESI_protocol) 的冲突副本。当这个核心处于修改状态时,另一个核心甚至不能拥有只读副本。使其非原子的原因是执行 RMW 的核心可能会失去对加载和存储之间的缓存线的所有权。
此外,不,整个缓存行并不总是以原子方式传输。请参阅 this answer,实验证明,多插槽 Opteron 通过使用超传输传输 8B 块中的高速缓存行使 16B SSE 存储成为非原子存储,即使它们对于单插槽 CPU 是原子的相同的类型(因为加载/存储硬件有一个 16B 的路径到 L1 缓存)。 x86 仅保证单独加载或存储高达 8B 的原子性。
让编译器对齐并不意味着内存将在 4 字节边界上对齐。编译器可以有选项或编译指示来更改对齐边界。例如,这对于在网络流中处理紧密打包的数据很有用。
诡辩,别无他法。如示例中所示,具有自动存储功能且不属于结构的整数将绝对正确对齐。声称任何不同的东西都是彻头彻尾的愚蠢。缓存行以及所有 POD 都是 PoT(二次幂)大小和对齐的——在世界上任何非虚幻架构上。数学表明,任何正确对齐的 PoT 都恰好适合任何其他相同大小或更大的 PoT 中的一个(永远不会更多)。因此我的说法是正确的。
@Damon,问题中给出的示例没有提到结构,但它并没有将问题缩小到整数不是结构的一部分的情况。 POD 绝对可以具有 PoT 大小,而不是 PoT 对齐。查看此答案以获取语法示例:stackoverflow.com/a/11772340/1219722。所以这几乎不是“诡辩”,因为以这种方式声明的 POD 在现实生活中的代码中相当多地用于网络代码。
C
Community

单个编译器的输出,在特定的 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

B
Bonita Montero

当您的编译器仅使用一条指令进行增量并且您的机器是单线程时,您的代码是安全的。 ^^


X
Xirema

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

num++ 出现 是原子的原因是因为在 x86 机器上,增加一个 32 位整数实际上是原子的(假设没有发生内存检索)。但这既不受 c++ 标准的保证,也不可能在不使用 x86 指令集的机器上出现。所以这段代码在竞争条件下不是跨平台安全的。

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

那么,我们有 std::atomic<int> 等的原因是,当您使用无法保证基本计算的原子性的架构时,您有一种机制将强制编译器生成原子代码。


“是因为在 x86 机器上,增加 32 位整数实际上是原子的。”你能提供证明文件的链接吗?
它在 x86 上也不是原子的。它是单核安全的,但如果有多个核心(并且有),它根本就不是原子的。
x86 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
@Slava @Harold @ShadowRanger 我更新了答案。 add 是原子的,但我明确表示这并不意味着代码是竞争条件安全的,因为更改不会立即全局可见。
@Xirema 根据定义使其“不是原子的”