GCC 使用 `memory_order_seq_cst` 跨负载重新排序。这是允许的吗?

Posted

技术标签:

【中文标题】GCC 使用 `memory_order_seq_cst` 跨负载重新排序。这是允许的吗?【英文标题】:GCC reordering up across load with `memory_order_seq_cst`. Is this allowed? 【发布时间】:2016-08-25 19:08:25 【问题描述】:

使用基本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&lt;std::size_t&gt;,它们是偶数(表示写入者当前没有写入)和相等(表示作者在seq_ 的两次加载之间没有写信给value)。此外,因为这些负载被标记为memory_order_seq_cst(作为默认参数),我想指令copy = value;将在每次迭代中执行,因为它不能在初始负载中重新排序,也不能在下面重新排序后者。

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

This test harness 暴露了我的 Xeon E3-1505M 上的行为,其中“已观察:2”将从阅读器中打印出来,并返回值 65535。 seq_ 的观察值和从 value 返回的负载的这种组合似乎违反了应该由发布 seq.store(2)memory_order_release 的写入线程和读取 seq_ 的读取线程建立的同步关系memory_order_seq_cst.

gcc 对负载重新排序是否有效,如果是,为什么它只在sizeof(value) sizeof(value) 都不会重新排序负载。我相信 Clang 的 codegen 是适当且正确的方法。

【问题讨论】:

我认为你应该向 gcc 的 bugzilla 报告。 Gimple 优化器保留它,但在 RTL 中,通过 pre+cse_local 进行转换。 @MarcGlisse,你是怎么走到这一步的? -fdump-rtl-all 然后查看转储。不过,它似乎是特定于这个目标的。 您可能应该为这些精细问题指明特定的 C++ 标准版本。 【参考方案1】:

注意:

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

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

C++11 标准说:

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

还有:

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

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

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

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

【讨论】:

-O2 仍然做了很多优化;你有任何证据表明它会使这个 UB 安全吗? (赞成是因为您正确地指出 seqlock 中的 value 也需要是原子的。)但是您需要 value.load() 发生在两个 seq.load()s 之间,而不是与它们中的任何一个重新排序。获取仅在 1 个方向上重新排序的块 (preshing.com/20120913/acquire-and-release-semantics),因此我认为您也需要 value.load() 作为获取负载。来自seq 的第二次加载虽然可以放宽,但仍然保证在value.load(mo_acquire) 之后发生。 哦,在这种情况下,OP 说它恰好适用于 gcc -O2。但是没有理由假设它对于其他目标通常是安全的(尤其是非 x86,其中常规加载没有免费获取语义)。 @PeterCordes - 我不认为编译器在这里依赖一些复杂的 UB 证明:请参阅我的答案,其中似乎 应该 是安全的类似代码也出现编译不安全。 您不能依赖编译器中的 1、2 或 3 级优化。不同版本的编译器可以移动它们。 @BeeOnRope 您可能是对的,这是一个错误,但就问题而言,这是允许的。我添加了一些引用来澄清它会导致未定义的行为。 :)【参考方案2】:

恭喜,我认为你在 gcc 中遇到了错误!

现在我认为您可以提出一个合理的论点,就像 other answer 所做的那样,您展示的原始代码可能可能已经被 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;

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

现在这是众所周知的习语的读取端,确实可以工作并且没有竞争:一个线程写入value,然后将seq0设置为非零并释放店铺。调用load 的线程看到非零存储,并与之同步,因此可以安全地读取value。当然,你不能一直写信给value,这是一次“一次性”初始化,但这是一种常见的模式。

使用上面的代码,gcc 是still hoisting the read of 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]

【讨论】:

以上是关于GCC 使用 `memory_order_seq_cst` 跨负载重新排序。这是允许的吗?的主要内容,如果未能解决你的问题,请参考以下文章

为可移植 C 库使用 GCC __sync 扩展

是否可以在 gcc 3.3+ 中以旧方式使用 __func__ ? (C++)

GCC使用-D参数定义类似函数的宏

avr-gcc:如何将 __attribute__((address)) 与 EEMEM 一起使用?

使用内联汇编器从 GCC 中的共享库调用函数

GCC - 如何重新对齐堆栈?