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

时间:2016-04-30 18:03:50

标签: 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;
}

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

但是,generated assemblyvalue首次加载之前从seq_发出加载,甚至在循环之外执行。这可能导致value的不正确同步或撕裂读取,这些读取无法通过seqlock算法解决。另外,我注意到这只发生在sizeof(value)低于123字节时。将value修改为某种类型&gt; = 123字节会产生正确的程序集,并在seq_的两个负载之间的每次循环迭代时加载。是否有任何理由为什么这个看似随意的阈值决定了哪个汇编产生了?

This test harness暴露了我的Xeon E3-1505M上的行为,其中&#34;观察到:2&#34;将从阅读器打印,将返回值65535。 seq_的观察值与来自value的返回加载的组合似乎违反了应由作者线程发布seq.store(2)memory_order_release建立的同步关系。读者使用seq_读取memory_order_seq_cst

gcc重新排序负载是否有效?如果是,为什么只有在sizeof(value)&lt; sizeof(value)时才这样做? 123? clang,无论优化级别如何,void LongestWord(string text) { string tmpWord = ""; string maxWord = ""; for(int i=0; i < text.length(); i++) { /// If founded space, rewrite word if(text[i] != ' ') tmpWord += text[i]; else tmpWord = ""; /// All the time check word length and if tmpWord > maxWord => Rewrite. if(tmpWord.length() > maxWord.length()) maxWord=tmpWord; } cout << "Longest Word: " << maxWord << endl; cout << "Word Length: " << maxWord.length() << endl; } 都不会重新排序负载。我认为,Clang的代码是正确的方法。

2 个答案:

答案 0 :(得分:3)

恭喜,我认为您已经遇到了gcc中的错误!

现在我认为你可以做出一个合理的论证,正如http://squib.readthedocs.io/en/v0.14.0/dsl/deck.html所做的那样,你展示的原始代码可能可能已经通过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或其他任何内容 - 它只是等待value从零变为非零,然后读取seq_while的第二次读取与value条件一样是多余的,但没有它们就会消失。

现在这是 工作且无竞争的众所周知的习语的读取方:一个线程写入seq0,然后设置load非发布商店为零。调用value的线程会看到非零存储,并与之同步,因此可以安全地读取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是other answer

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

糟糕!

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

{{1}}

答案 1 :(得分:2)

通常不允许重新排序此类操作,但在这种情况下允许它,因为您通过在非读取中交错非原子读取和写入来在读取中创建竞争条件来调用未定义的行为线程。

C ++ 11标准说:

  

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

还有:

  

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

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

  

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

因为从非原子写入读取会产生未定义的行为(即使您覆盖并忽略该值),GCC也可以假定它不会发生,从而优化了seqlock。

基于另一个答案,这似乎实际上是由于GCC中的一个错误在修复UB时仍然存在,但是自从您调用UB以来,该优化对于您的代码而言技术上无效。