GCC通过`memory_order_seq_cst`重新排序.这是允许的吗?

Ale*_*dro 10 c++ multithreading gcc memory-barriers stdatomic

使用基本seqlock的简化版本 ,gcc load(memory_order_seq_cst)在编译代码时重新排序原子上的非原子加载-O3.在使用其他优化级别进行编译或使用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;
}
Run Code Online (Sandbox Code Playgroud)

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

但是,生成的组件会value在第一次加载之前发出负载,seq_甚至在循环之外执行.这可能导致不正确的同步或撕裂的读取value不会被seqlock算法解决.另外,我注意到这只发生在 sizeof(value)123字节以下.修改value为某种类型> = 123字节会产生正确的程序集,并在两次加载之间的每次循环迭代时加载seq_.是否有任何理由为什么这个看似任意的阈值决定了哪个组件生成?

此测试工具 暴露了我的Xeon E3-1505M上的行为,其中将从阅读器打印"Observed:2",并返回值65535.的观测值的组合seq_,并从返回的负荷value似乎违反了下同步,与关系,应该由写线程出版建立seq.store(2)memory_order_release和读取器线程读取seq_使用memory_order_seq_cst.

gcc是否对重新排序负载有效,如果是,为什么它只在sizeof(value)<123 时才这样做?clang,无论是优化级别还是sizeof(value)不会重新排序负载.我认为,Clang的代码是正确的方法.

Bee*_*ope 5

恭喜,我认为您遇到了错误gcc

现在我认为你可以做出一个合理的论点,就像其他答案一样,你所展示的原始代码也许可以gcc通过依赖关于无条件访问的相当模糊的论点来正确优化value:本质上你不能有一直依赖于加载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;
}
Run Code Online (Sandbox Code Playgroud)

这不是 aseqlock或任何东西 - 它只是等待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
Run Code Online (Sandbox Code Playgroud)

哎呀!

此行为在 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]
Run Code Online (Sandbox Code Playgroud)

  • @rnpl - 我没有关注。“第二次读取”是指源代码中显示的第二次读取,位于行“} while (!seq0);”的正上方?如果发生了该读取,则副本已在上面的行中分配。我看不出这个循环可以在不设置副本的情况下退出:它只能通过到达底部的“while”来退出,这意味着没有控制流的前两行也必须已执行,并且第一行这些集合“复制”。 (2认同)

Exa*_*eta 2

通常不允许对此类操作进行重新排序,但在这种情况下是允许的,因为任何会产生不同结果的并发执行代码都必须通过交错非原子读取和(原子或非原子)在不同的线程中写入。

C++11 标准规定:

如果两个表达式求值之一修改内存位置 (1.7),而另一个表达式求值访问或修改同一内存位置,则两个表达式求值会发生冲突。

还有:

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

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

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

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