C++中关于原子加载存储的优化

10

我已经阅读了C++中的std::memory_order,并且部分理解了它。但是我仍然对它有一些疑问。

  1. std::memory_order_acquire的说明称,在此加载之前,当前线程中的任何读取或写入都不会被重新排序。这是否意味着编译器和CPU不允许将acquire语句下面的任何指令移动到上面?
auto y = x.load(std::memory_order_acquire);
z = a;  // is it leagal to execute loading of shared `b` above acquire? (I feel no)
b = 2;  // is it leagal to execute storing of shared `a` above acquire? (I feel yes)

我可以理解为什么在acquire之前执行负载是不合法的。但为什么存储也是不合法的呢?

  1. 跳过 atomic 对象中无用的负载或存储是否违法?因为它们不是volatile,而我所知道的只有volatile才有这个要求。
auto y = x.load(std::memory_order_acquire);  // `y` is never used
return;

即使使用relaxed内存顺序,也无法进行此优化。

  1. 编译器是否允许将位于acquire语句上方的指令移动到其下方?
z = a;  // is it leagal to execute loading of shared `b` below acquire? (I feel yes)
b = 2;  // is it leagal to execute storing of shared `a` below acquire? (I feel yes)
auto y = x.load(std::memory_order_acquire);

4. 在不跨越 acquire 边界的情况下,两个负载或存储操作可以重新排序吗?
auto y = x.load(std::memory_order_acquire);
a = p;  // can this move below the below line?
b = q;  // shared `a` and `b`

同样,与“发布”语义相关的有4个疑问也有相应的疑问。

与第2和第3个问题相关,为什么没有编译器像下面的代码中g()一样积极地优化f()

#include <atomic>

int a, b;

void dummy(int*);

void f(std::atomic<int> &x) {
    int z;
    z = a;  // loading shared `a` before acquire
    b = 2;  // storing shared `b` before acquire
    auto y = x.load(std::memory_order_acquire);
    z = a;  // loading shared `a` after acquire
    b = 2;  // storing shared `b` after acquire
    dummy(&z);
}

void g(int &x) {
    int z;
    z = a;
    b = 2;
    auto y = x;
    z = a;
    b = 2;
    dummy(&z);
}

f(std::atomic<int>&):
        sub     rsp, 24
        mov     eax, DWORD PTR a[rip]
        mov     DWORD PTR b[rip], 2
        mov     DWORD PTR [rsp+12], eax
        mov     eax, DWORD PTR [rdi]
        lea     rdi, [rsp+12]
        mov     DWORD PTR b[rip], 2
        mov     eax, DWORD PTR a[rip]
        mov     DWORD PTR [rsp+12], eax
        call    dummy(int*)
        add     rsp, 24
        ret
g(int&):
        sub     rsp, 24
        mov     eax, DWORD PTR a[rip]
        mov     DWORD PTR b[rip], 2
        lea     rdi, [rsp+12]
        mov     DWORD PTR [rsp+12], eax
        call    dummy(int*)
        add     rsp, 24
        ret
b:
        .zero   4
a:
        .zero   4

2
这是一份非常有价值的信息:C++ and Beyond 2012: Herb Sutter - atomic Weapons 1 of 2 - Craig Estey
2
这对于商店来说是非法的,因为获取可能是原子RMW或双重检查锁定的一部分,需要锁定以与其他线程进行互斥。或者,看到另一个线程已经完成了它要做的事情,涉及到非原子对象。完全锁定需要原子RMW或seq_cst加载/存储,但您可能会遇到另一个线程完成写入某些内容,并通过将其释放存储到标志来发出信号的情况。 - Peter Cordes
1
回复:未完成的优化,如删除未使用的载入:ISO C ++允许这样做,但当前编译器将atomic视为volatile,在进一步处理编译器并设计防止对时序可能有问题的优化方法之前暂不进行优化:为什么编译器不合并冗余的std :: atomic写入? - Peter Cordes
你在Q1中的例子有点棘手。你会如何完成程序,以便可以实际观察到所讨论的重新排序?我能想到的所有方法都会创建数据竞争,或者至少是潜在的数据竞争,这使得整个程序UB,因此任何事情都可能发生。 - Nate Eldredge
@CraigEstey 谢谢,演讲很棒。但是我对演讲中提出的一些声明有疑问。我已经在这里发表了相关问题。 - Sourav Kannantha B
1个回答

4

Q1

通常是这样的。在程序顺序中跟随(或存储)获取加载的任何负载,在其之前不能变得可见。

下面是一个需要注意的示例:

#include <atomic>
#include <thread>
#include <iostream>

std::atomic<int> x{0};
std::atomic<bool> finished{false};
int xval;
bool good;

void reader() {
    xval = x.load(std::memory_order_relaxed);
    finished.store(true, std::memory_order_release);
}

void writer() {
    good = finished.load(std::memory_order_acquire);
    x.store(42, std::memory_order_relaxed);
}

int main() {
    std::thread t1(reader);
    std::thread t2(writer);
    t1.join();
    t2.join();
    if (good) {
        std::cout << xval << std::endl;
    } else {
        std::cout << "too soon" << std::endl;
    }
    return 0;
}

在godbolt上尝试

该程序没有UB(未定义行为),必须打印0too soon。如果将writer将42存储到x并重新排序,使其在加载finished之前,则可能会使reader加载x返回42,writer加载finished返回true,在这种情况下,程序将不正确地打印42

Q2

是的,编译器删除永远不使用的原子加载是可以的,因为符合规范的程序无法检测是否已完成加载。但是,当前的编译器通常不会执行此类优化。部分原因是出于谨慎起见,因为对原子操作的优化很难做到正确,并且错误非常微妙。这也可能部分原因是为了支持编写依赖于实现的代码,可以通过非标准特性检测加载是否已完成。

Q3

是的,这种重新排序是完全合法的,在实际应用中会这样做。获取屏障只是一种方式。

Q4

是的,这也是合法的。如果a,b不是原子的,并且一些其他线程正在并发地读取它们,则代码存在数据竞争和UB,因此如果其他线程观察到写入以错误的顺序发生(或召唤鼻中隔恶魔),那么就可以了。(如果它们是原子的,并且您正在执行松散的存储,则无法得到鼻中隔恶魔,但您仍然可以观察到存储的顺序;没有规定相反的happens-before关系。)

优化比较

您的fg示例实际上不是公平的比较:在g中,非原子变量x的加载没有副作用,并且其值未被使用,因此编译器完全省略了它。如上所述,编译器不会省略f中不必要的原子加载x

至于为什么编译器不将对ab的第一个访问下沉到获取加载之后:我认为这只是一个被忽略的优化。再次强调,大多数编译器故意不尝试使用原子操作进行所有可能的合法优化。但是,您可以注意到,在ARM64等处理器上,fx的加载编译为ldar,CPU绝对可以将其与早期的普通加载和存储重新排序。


1
一种总结获取负载相对于后续存储器排序原因的方法是确保这些存储器不会被代码中“happens-before”释放存储器的存储器所覆盖。顺便说一句,std::endl在这里既不必要也没有用处,您可以使用"too soon\n"。它与<< '\n'和fflush完全等效,即使在使用文本流中的不同换行约定的系统上也是如此。(即std::endl的目的并不是可移植的行尾,尽管名称的选择很不幸。) - Peter Cordes
@Nate 为什么即使x的值没有被使用,f中x的原子加载也被认为是副作用呢? - Sourav Kannantha B
1
@SouravKannanthaB:原子加载不是副作用。我可能表达得不太好。但是如上所述,编译器通常不会将其优化掉。在这方面,它类似于加载volatile变量,后者被认为是副作用。 - Nate Eldredge

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