仅仅读取原子变量与普通变量相比,是否存在性能差异?

9
int i = 0;
if(i == 10)  {...}  // [1]

std::atomic<int> ai{0};
if(ai == 10) {...}  // [2]
if(ai.load(std::memory_order_relaxed) == 10) {...}  // [3]

在多线程环境下,语句[1]是否比语句[2]和[3]更快?
假设当[2]和[3]正在执行时,ai可能被写入另一个线程。

附加说明:如果底层整数的准确值不是必需的,那么读取原子变量的最快方法是什么?


3
是的,[1] 应该更快。根据架构不同,[2] 需要栅栏或锁指令。 - Igor Tandetnik
1
此外,如果i可以在多个线程中被读写,那么就会出现数据竞争和未定义行为,这将完全抵消任何性能提升。 - NathanOliver
在我的电脑上,当进行优化时,[1] 大约比 [2] 快40倍。但这可能因平台和编译器(以及编译器的优化)而有很大差异。 - Eljay
@NathanOliver,代码预计在使用Qt的各种系统上运行。关于您对UB的评论,它只会影响数据的准确性还是可能会导致系统崩溃(一种UB)? - iammilind
1
如果你应该使用atomic<>,但没有使用它,通常的后果是像MCU编程-C++ O2优化中断while循环那样的情况-通过提升负荷, while(!read){} 循环变成了 if(!ready) infinite_loop(); - Peter Cordes
显示剩余4条评论
3个回答

10
这取决于架构,但一般来说,负载是便宜的,与具有严格内存排序的存储器配对可能会很昂贵。

在x86_64上,最多64位的加载和存储本身是原子的(但读-修改-写则明显不是)。

就像您所拥有的那样,C ++中的默认内存排序是std::memory_order_seq_cst,它为您提供了顺序一致性,即:所有线程将看到发生的负载/存储的某些顺序。为了在x86(以及所有多核系统)上实现这一点,需要在存储器上进行内存屏障,以确保在存储后发生的负载读取新值。

在这种情况下,读取在强序x86上不需要内存屏障,但是写入需要。在大多数弱序ISA上,甚至seq_cst读取都需要一些屏障指令,但不需要完整的屏障。如果我们查看此代码:

#include <atomic>
#include <stdlib.h>

int main(int argc, const char* argv[]) {
    std::atomic<int> num;

    num = 12;
    if (num == 10) {
        return 0;
    }
    return 1;
}

使用 -O3 编译:

   0x0000000000000560 <+0>:     sub    $0x18,%rsp
   0x0000000000000564 <+4>:     mov    %fs:0x28,%rax
   0x000000000000056d <+13>:    mov    %rax,0x8(%rsp)
   0x0000000000000572 <+18>:    xor    %eax,%eax
   0x0000000000000574 <+20>:    movl   $0xc,0x4(%rsp)
   0x000000000000057c <+28>:    mfence 
   0x000000000000057f <+31>:    mov    0x4(%rsp),%eax
   0x0000000000000583 <+35>:    cmp    $0xa,%eax
   0x0000000000000586 <+38>:    setne  %al
   0x0000000000000589 <+41>:    mov    0x8(%rsp),%rdx
   0x000000000000058e <+46>:    xor    %fs:0x28,%rdx
   0x0000000000000597 <+55>:    jne    0x5a1 <main+65>
   0x0000000000000599 <+57>:    movzbl %al,%eax
   0x000000000000059c <+60>:    add    $0x18,%rsp
   0x00000000000005a0 <+64>:    retq

我们可以看到,在+31处从原子变量中读取不需要任何特殊操作,但因为我们在+20处写入了原子变量,编译器必须在此之后插入一个mfence指令,以确保该线程在执行任何后续加载操作之前等待其存储变得可见。这是一种昂贵的操作,会阻塞该核心,直到存储缓冲区排空。(在某些x86 CPU上,后续非内存指令的乱序执行仍然可能发生。)
如果我们在写入时使用较弱的排序(例如std::memory_order_release):
#include <atomic>
#include <stdlib.h>

int main(int argc, const char* argv[]) {
    std::atomic<int> num;

    num.store(12, std::memory_order_release);
    if (num == 10) {
        return 0;
    }
    return 1;
}

那么在x86上,我们不需要栅栏:

   0x0000000000000560 <+0>:     sub    $0x18,%rsp
   0x0000000000000564 <+4>:     mov    %fs:0x28,%rax
   0x000000000000056d <+13>:    mov    %rax,0x8(%rsp)
   0x0000000000000572 <+18>:    xor    %eax,%eax
   0x0000000000000574 <+20>:    movl   $0xc,0x4(%rsp)
   0x000000000000057c <+28>:    mov    0x4(%rsp),%eax
   0x0000000000000580 <+32>:    cmp    $0xa,%eax
   0x0000000000000583 <+35>:    setne  %al
   0x0000000000000586 <+38>:    mov    0x8(%rsp),%rdx
   0x000000000000058b <+43>:    xor    %fs:0x28,%rdx
   0x0000000000000594 <+52>:    jne    0x59e <main+62>
   0x0000000000000596 <+54>:    movzbl %al,%eax
   0x0000000000000599 <+57>:    add    $0x18,%rsp
   0x000000000000059d <+61>:    retq   

请注意,如果我们将相同的代码编译为AArch64:
   0x0000000000400530 <+0>:     stp  x29, x30, [sp,#-32]!
   0x0000000000400534 <+4>:     adrp x0, 0x411000
   0x0000000000400538 <+8>:     add  x0, x0, #0x30
   0x000000000040053c <+12>:    mov  x2, #0xc
   0x0000000000400540 <+16>:    mov  x29, sp
   0x0000000000400544 <+20>:    ldr  x1, [x0]
   0x0000000000400548 <+24>:    str  x1, [x29,#24]
   0x000000000040054c <+28>:    mov  x1, #0x0
   0x0000000000400550 <+32>:    add  x1, x29, #0x10
   0x0000000000400554 <+36>:    stlr x2, [x1]
   0x0000000000400558 <+40>:    ldar x2, [x1]
   0x000000000040055c <+44>:    ldr  x3, [x29,#24]
   0x0000000000400560 <+48>:    ldr  x1, [x0]
   0x0000000000400564 <+52>:    eor  x1, x3, x1
   0x0000000000400568 <+56>:    cbnz x1, 0x40057c <main+76>
   0x000000000040056c <+60>:    cmp  x2, #0xa
   0x0000000000400570 <+64>:    cset w0, ne
   0x0000000000400574 <+68>:    ldp  x29, x30, [sp],#32
   0x0000000000400578 <+72>:    ret

当我们写入变量位于+36时,我们使用Store-Release指令(stlr),而+40处的加载使用Load-Acquire(ldar)。这些都提供了部分内存屏障(并共同形成完整的屏障)。
只有在必须推断变量的访问顺序时才应使用atomic(原子性)。为回答您的附加问题,请使用std :: memory_order_relaxed来读取atomic中的内存,不能保证与写入同步。只有原子性是得到保证的。

1
[mo_seq_cst]要求存储时进行内存屏障以确保更改可见。 不完全正确。它需要内存屏障以确保后续来自此线程的加载在前面的存储全局可见之后才发生。存储总是会自行变得可见,屏障只是使当前线程等待它们。 - Peter Cordes
1
谢谢;由于有关 CPU 和原子操作的工作方式存在广泛的误解(例如,CPU 数据缓存可能不同步;实际上,编译器将值“缓存”在寄存器中),我认为重要的是吹毛求疵这样的细节。 - Peter Cordes
1
看起来你只改了一个需要修改的地方;我改了我正在评论的那个,它也有一个可疑的短语,关于缓存需要“时间同步”。排空存储缓冲区的时间可能包括为不属于此核心的缓存行发出RFO请求的时间,但缓存永远不会失去同步;这就是MESI等一致性协议的全部意义。 - Peter Cordes
1
顺便说一句,我不会写一个 main 函数,我只会写一个函数,该函数接受一个 atomic<int> 的引用或指针。此外,我会使用 -fno-stack-protector 编译选项来简化汇编代码。不确定为什么 GCC 会在唯一的本地变量是 atomic<int> 时生成堆栈 cookie,但是 mov %fs:0x28,%rax 仍然存在。也许 main 是特殊的?(https://godbolt.org/ 默认情况下不启用 -fstack-protector-strong;我通常在那里编译以复制/粘贴到 SO)。 - Peter Cordes
1
@user179156: 我对Java中的volatile的理解是,它基本上相当于C++中的std::atomic<T>,默认使用mo_seq_cst。而*C++*中的volatile则与std::atomic<T>的mo_relaxed模式有些相似。 - undefined
显示剩余3条评论

2
这里提出的三种情况具有不同的语义,因此在线程启动后从未写入值时,讨论它们的相对性能可能是没有意义的。
第一种情况:
int i = 0;
if(i == 10)  {...}  // may actually be optimized away since `i` is clearly 0 now

如果i被多个线程访问,其中包括一个操作,则行为是未定义的。
在没有同步的情况下,编译器可以假设没有其他线程可以修改i,并且可以重新排列/优化对它的访问。例如,它可能将i加载到寄存器中一次,从不再从内存中重新读取它,或者它可能将写出循环并仅在最后一次写入。 情况2:
std::atomic<int> ai{0};
if(ai == 10) {...}  // [2]

默认情况下,对 atomic 的读写是在 std::memory_order_seq_cst(顺序一致性)内存顺序中完成的。这意味着不仅对 ai 原子变量的读/写是原子操作,而且它们也会及时地被其他线程看到,包括在其之前/之后读/写的任何其他变量。
因此,读/写 atomic 就像一个内存屏障。然而,这样做要慢得多,因为 (1) SMP 系统必须在处理器之间同步缓存,以及 (2) 编译器在围绕原子访问优化代码时的自由度更小。 案例3:
std::atomic<int> ai{0};
if(ai.load(std::memory_order_relaxed) == 10) {...}  // [3]

这种模式仅允许并保证对ai的读/写是原子性的。因此,编译器可以自由地重新排序对它的访问,并且仅保证写操作在合理的时间内对其他线程可见。
它的适用范围非常有限,因为它使得在程序中推断事件的顺序变得非常困难。例如:
std::atomic<int> ai{0}, aj{0};

// thread 1
aj.store(1, std::memory_order_relaxed);
ai.store(10, std::memory_order_relaxed);

// thread 2
if(ai.load(std::memory_order_relaxed) == 10) {
  aj.fetch_add(1, std::memory_order_relaxed);
  // is aj 1 or 2 now??? no way to tell.
}

这种模式潜在地(通常情况下)比第一种情况更慢,因为编译器必须确保每次读/写实际上都会传输到缓存/RAM中,但比第二种情况要快,因为仍然可以优化周围的其他变量。
有关原子操作和内存排序的更多详细信息,请参阅Herb Sutter的出色原子武器演讲

seq_cst原子加载不必是完整的内存屏障。通常只有seq_cst原子的存储端承担完整屏障的负担,以确保加载操作可以更加廉价(https://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html)。AArch64相当独特:`stlr`(释放-存储)无法通过`ldar`(获取-加载),因此您可以在任何地方获得seq_cst而无需实际的完整屏障。如果您在seq_cst存储之后不久进行seq_cst加载(因为这将强制清空存储缓冲区),则会获得出色的性能。即`stlr`是一个顺序释放,但它的实现方式不同。 - Peter Cordes
1
确保每个读/写实际上都进入RAM。是内存/RAM,但不是DRAM。它不必刷新或绕过缓存,这是一个常见的误解,因此我会用不同的措辞来防止对你所说的内容产生错误解释。缓存是一致的,因此只需确保装载或存储发生在汇编中,而不是被优化掉或在寄存器中保留值即可。如果有人理解优化的含义,mo_relaxedvolatile类似。 - Peter Cordes
"std::memory_order_seq_cst ... 意味着 ... 它们也能及时地被其他线程看到。" 那其他内存顺序就不意味着这个? - curiousguy

1
关于您在UB上的评论,它只会影响数据的准确性还是会导致系统崩溃(一种UB)?
如果您在读取时没有使用atomic<>,通常的后果就像MCU programming - C++ O2 optimization breaks while loop一样。
例如,一个while(!read){}循环通过提升负载变成if(!ready) infinite_loop();
不要这么做;如果/当可以的话,请在源代码中手动提升原子负载,例如int localtmp = shared_var.load(std::memory_order_relaxed);

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接