与普通变量相比,仅仅读取原子变量的性能有什么不同吗

Is there any performance difference in just reading an atomic variable compared to a normal variable?

本文关键字:变量 性能 什么 读取      更新时间:2023-10-16
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] 在多线程环境中
假设ai可能被写入也可能不被写入另一个线程,当[2]&[3] 正在执行。

加载项:假设基础整数的准确值不是必需的,那么读取原子变量的最快方法是什么?

这取决于体系结构,但通常负载很便宜,但与具有严格内存排序的存储配对可能会很昂贵。

在x86_64上,高达64位的加载和存储本身是原子的(但读-修改-写显然是而不是)。

正如您所知,C++中的默认内存顺序是std::memory_order_seq_cst,这为您提供了顺序一致性,即:所有线程都会按照一定的顺序看到加载/存储。要在x86(实际上是所有多核系统)上实现这一点,需要在存储上设置内存围栏,以确保在存储读取新值后发生的加载。

在这种情况下,读取不需要强有序x86上的内存围栏,但写入需要。在大多数弱序ISAs上,即使是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写入变量时,我们使用存储释放指令(stlr),在+40加载时使用加载获取(ldar)。它们各自提供一个部分内存围栏(并一起形成一个完整围栏)。

只有当来推断变量的访问顺序时,才应该使用atomic。要回答您的附加组件问题,请使用std::memory_order_relaxed使内存读取原子,而不保证与写入同步。只有原子性是有保证的。

给出的3种情况具有不同的语义,因此对它们的相对性能进行推理可能毫无意义,除非在线程启动后从未写入值。

情况1:

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.
}

这种模式可能(而且经常)比情况1慢,因为编译器必须确保每次读/写都能真正到达缓存/RAM,但比情况2快,因为仍然可以优化它周围的其他变量。

有关原子论和内存排序的更多详细信息,请参阅Herb Sutter的优秀原子<gt;武器谈话。

关于您对UB的评论,是否只会影响数据的准确性,或者会导致系统崩溃(有点像UB)?

如果在应该读取的时候不使用atomic<>,通常的结果是MCU编程之类的东西-循环时C++O2优化中断

例如CCD_ 15环路通过提升负载而变成CCD_。

只是不要这么做;如果可以的话,手动提升源中的原子负载,如int localtmp = shared_var.load(std::memory_order_relaxed);