GCC 在加载过程中使用“memory_order_seq_cst”重新排序.这是允许的吗

GCC reordering up across load with `memory_order_seq_cst`. Is this allowed?

本文关键字:排序 新排序 过程中 加载 cst seq order memory GCC      更新时间:2023-10-16

使用基本seqlock的简化版本,gcc在使用-O3编译代码时,在原子load(memory_order_seq_cst)上重新排序非原子负载。使用其他优化级别进行编译或使用 clang 进行编译时(即使在 O3 上(时,也不会观察到这种重新排序。这种重新排序似乎违反了应该建立的同步关系,我很想知道为什么 gcc 会重新排序这个特定的负载,以及标准是否允许这样做。

请考虑以下load函数:

auto load()
{
    std::size_t copy;
    std::size_t seq0 = 0, seq1 = 0;
    do
    {
        seq0 = seq_.load();
        copy = value;
        seq1 = seq_.load();
    } while( seq0 & 1 || seq0 != seq1);
    std::cout << "Observed: " << seq0 << 'n';
    return copy;
}

按照 seqlock 过程,这个读取器旋转,直到它能够加载两个seq_实例,它被定义为一个std::atomic<std::size_t>,它们是偶数(表示编写器当前没有写入(和相等(表示写入器没有写入value在两个加载seq_之间(。此外,由于这些负载被标记为memory_order_seq_cst(作为默认参数(,我可以想象指令copy = value;将在每次迭代中执行,因为它不能在初始加载中重新排序,也不能在后者下方重新排序。

但是,生成的程序集在从seq_发出第一次加载之前发出来自value的加载,甚至在循环外部执行。这可能会导致同步不正确或 seqlock 算法无法解析的value读取中断。此外,我注意到这仅在sizeof(value)低于 123 字节时发生。将value修改为某种类型>= 123 字节会产生正确的程序集,并在两次seq_加载之间的每次循环迭代时加载。这个看似任意的阈值有什么理由决定生成哪个程序集?

此测试工具公开了我的至强 E3-1505M 上的行为,其中将从读取器打印"观察到:2",并将返回值 65535。观察到的 seq_ 值和从value返回的负载的这种组合似乎违反了编写器线程发布seq.store(2)memory_order_release和读取器线程读取seq_memory_order_seq_cst建立的同步关系。

gcc 重新排序负载是否有效,如果是,为什么它只在 sizeof(value) <123 时才这样做? clang,无论优化级别还是sizeof(value)都不会对负载进行重新排序。我相信,Clang的Codegen是适当和正确的方法。

恭喜,我想你在gcc中遇到了一个错误!

现在我认为你可以像另一个答案一样提出一个合理的论点,即你展示的原始代码也许可以通过依赖一个关于无条件访问value的相当晦涩的论点来正确优化gcc:本质上你不能依赖负载seq0 = seq_.load();和随后的value读取之间的同步关系, 因此,在"其他地方"阅读它不应该改变无种族程序的语义。我实际上不确定这个论点,但这是我从减少代码中获得的一个"更简单"的情况:

#include <atomic>
#include <iostream>
std::atomic<std::size_t> seq_;
std::size_t value;
auto load()
{
    std::size_t copy;
    std::size_t seq0;
    do
    {
        seq0 = seq_.load();
        if (!seq0) continue;
        copy = value;
        seq0 = seq_.load();
    } while (!seq0);
    return copy;
}

这不是seqlock或任何东西 - 它只是等待seq0从零变为非零,然后读取value.seq_的第二次阅读是多余的,while条件也是多余的,但没有它们,错误就会消失。

现在,这是众所周知的成语的读取端,它确实有效并且没有种族:一个线程写入value,然后使用发布存储将seq0设置为非零。调用load的线程看到非零存储,并与之同步,因此可以安全地读取value。当然,你不能一直写到value,这是一个"一次性"初始化,但这是一种常见的模式。

使用上面的代码,gcc 仍在提升value的读取:

load():
        mov     rax, QWORD PTR value[rip]
.L2:
        mov     rdx, QWORD PTR seq_[rip]
        test    rdx, rdx
        je      .L2
        mov     rdx, QWORD PTR seq_[rip]
        test    rdx, rdx
        je      .L2
        rep ret

哎呀!

此行为在 gcc 7.3 之前会发生,但在 8.1 中不会发生。您的代码也可以在 8.1 中根据需要进行编译:

    mov     rbx, QWORD PTR seq_[rip]
    mov     rbp, QWORD PTR value[rip]
    mov     rax, QWORD PTR seq_[rip]

通常不允许对此类操作进行重新排序,但在这种情况下是允许的,因为任何并发执行的代码都会产生不同的结果,从而调用未定义的行为,方法是通过在不同的线程中交错非原子读取和(原子或非原子(写入在读取中创建竞争条件。

C++11标准说:

如果两个表达式计算中的一个

修改内存位置 (1.7(,而另一个表达式计算修改内存位置,则两个表达式计算发生冲突访问或修改相同的内存位置。

还有:

如果程序在不同线程中包含两个冲突的操作,则程序的执行包含数据争用,其中至少一个不是原子的,并且两者都不会先于另一个发生。任何此类数据竞争都会导致未定义的行为。

这甚至适用于在未定义行为之前发生的事情:

执行格式良好的程序的符合要求的实现应产生相同的可观察行为作为具有相同程序的抽象机器的相应实例的可能执行之一和相同的输入。但是,如果任何此类执行包含未定义的操作,则此 国际标准对使用该输入执行该程序的实现没有要求(甚至没有要求关于第一个未定义操作之前的操作(。

因为从那里的写入中读取非原子会产生未定义的行为(即使您覆盖并忽略该值(,GCC 可以假设它不会发生,从而优化 seqlock。它可以这样做,因为任何导致循环多次执行的初始(获取(状态都不能防止非原子读取的后续竞争条件,因为超出初始获取状态的任何后续原子或非原子写入都不会在非原子读取之前与加载操作建立有保证的同步关系。也就是说,在执行 seq cst 加载和后续读取之间,对非原子读取变量的写入可能发生,这是一种争用条件。这个"可能"发生的事实是指向缺乏与关系同步的指针,因此未定义的行为,因此编译器可能会假设它不会发生,这允许它假设在循环期间不会对该变量进行任何并发写入。