10

使用基本 seqlock的简化版本,gcc 在load(memory_order_seq_cst)使用-O3. 使用其他优化级别编译或使用 clang(甚至 on 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_,这两个实例被定义为 a std::atomic<std::size_t>,它们是偶数(表示写入者当前未写入)和相等(表示写入者尚未写入value在两个负载之间seq_)。此外,由于这些负载被标记为memory_order_seq_cst( 作为默认参数 ),我想该指令copy = value;将在每次迭代时执行,因为它不能在初始负载中重新排序,也不能在后者之下重新排序。

但是,生成的程序集value在第一次加载之前发出加载,seq_甚至在循环之外执行。这可能导致不正确的同步或valueseqlock 算法无法解决的读取中断。此外,我注意到这只发生在 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 的 codegen 是适当且正确的方法。

4

2 回答 2

3

恭喜,我认为你遇到了一个错误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;
}

这不是 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

哎呀!

这种行为直到 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]
于 2018-05-29T02:22:31.067 回答
2

笔记:

根据另一个答案,这似乎实际上是由 GCC 中的一个错误引起的,当您修复 UB 时该错误仍然存​​在,但是自从您调用 UB 以来,该优化在技术上对您的代码并不无效,如下所述。

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

C++11 标准说:

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

还有:

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

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

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

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

于 2018-05-28T22:44:09.560 回答