为 x86 编译 C++ 时会发生 StoreStore 重新排序

StoreStore reordering happens when compiling C++ for x86

while(true) {
    int x(0), y(0);

    std::thread t0([&x, &y]() {
        x=1;
        y=3;
    });
    std::thread t1([&x, &y]() {
        std::cout << "(" << y << ", " <<x <<")" << std::endl;

    });


    t0.join();
    t1.join();
}

首先,由于数据竞争,我知道它是UB。 但是,我只希望得到以下输出:

(3,1), (0,1), (0,0)

我确信不可能得到 (3,0),但我做到了。所以我很困惑-毕竟 x86 doesn't allow StoreStore reordering.

所以 x = 1 应该在 y = 3

之前全局可见

我想从理论上看输出 (3,0) 是不可能的,因为 x86 内存模型。我想它的出现是因为 UB。但我不确定。请解释。

除了 StoreStore 重新排序之外还有什么可以解释得到 (3,0)

您正在使用内存模型较弱的 C++ 编写。您没有对 prevent reordering at compile-time.

做任何事情

如果您查看 asm,您可能会发现存储以与源相反的顺序发生,and/or加载以与您期望的相反的顺序发生。

加载在源代码中没有任何顺序:如果编译器愿意,它可以在加载 y 之前加载 x,即使它们是 std::atomic 类型:

            t2 <- x(0)
t1 -> x(1)
t1 -> y(3)
            t2 <- y(3)

这甚至不是 "re"排序,因为一开始就没有定义的顺序:

std::cout << "(" << y << ", " <<x <<")" << std::endl; 不一定在 x 之前计算 y<< 运算符具有从左到右的结合性,运算符重载只是

的语法糖
op<<( op<<(op<<(y),x), endl);  // omitting the string constants.

因为 the order of evaluation of function arguments is undefined (even if we're talking about nested function calls),编译器可以在计算 op<<(y) 之前自由计算 x。 IIRC,gcc 通常只是从右到左求值,必要时匹配将 args 推入堆栈的顺序。链接问题的答案表明情况经常如此。但当然,这种行为绝不能得到任何保证。

它们的加载顺序未定义,即使它们是 std::atomic。我不确定 xy 的计算之间是否存在 sequence point。如果不是,那么它将与计算 x+y 相同:编译器可以自由地以任何顺序计算操作数,因为它们是无序的。如果有一个序列点,那么就有一个顺序,但未定义哪个顺序(即它们的顺序不确定)。


略有相关:gcc doesn't reorder non-inline function calls in expression evaluation, to take advantage of the fact that C leaves the order of evaluation unspecified。我假设在内联后它确实优化得更好,但在这种情况下你没有给它任何理由支持在 x.

之前加载 y

如何正确操作

关键是编译器决定重新排序的确切原因并不重要,只是允许。如果您不强加所有必要的排序要求,您的代码就会有错误,句号。它是否碰巧与某些具有特定周围代码的编译器一起工作并不重要;那只是意味着它是一个潜在的错误。

有关 how/why 的文档,请参阅 http://en.cppreference.com/w/cpp/atomic/atomic 这有效:

// Safe version, which should compile to the asm you expected.

while(true) {
    int x(0);                  // should be atomic, too, because it can be read+written at the same time.  You can use memory_order_relaxed, though.
    std::atomic<int> y(0);

    std::thread t0([&x, &y]() {
        x=1;
        // std::atomic_thread_fence(std::memory_order_release);  // A StoreStore fence is an alternative to using a release-store
        y.store(3, std::memory_order_release);
    });
    std::thread t1([&x, &y]() {
        int tx, ty;
        ty = y.load(std::memory_order_acquire);
        // std::atomic_thread_fence(std::memory_order_acquire);  // A LoadLoad fence is an alternative to using an acquire-load
        tx = x;
        std::cout << ty + tx << "\n";   // Don't use endl, we don't need to force a buffer flush here.
    });

    t0.join();
    t1.join();
}

为了 Acquire/Release semantics 为您提供所需的顺序,最后一个存储必须是发布存储,获取加载必须是第一个加载。这就是为什么我将 y 设为 std::atomic,即使您将 x 设置为 0 或 1 更像是一个标志。

如果您不想使用 release/acquire,您可以在商店之间放置一个 StoreStore 栅栏,在负载之间放置一个 LoadLoad 栅栏。在 x86 上,这只会阻止编译时重新排序,但在 ARM 上你会得到一个内存屏障指令。 (请注意,y 在技术上仍然需要是原子的才能遵守 C 的数据竞争规则,但您可以在其上使用 std::memory_order_relaxed。)

实际上,即使 Release/Acquire 为 y 排序,x 也应该是原子的 。即使我们看到 y==0,x 的负载仍然会发生。所以线程2读x和线程1写y不同步,所以是UB。实际上,。但请记住 std::atomic 暗示其他语义,例如该值可以由其他线程异步更改的事实。


如果您在存储 i-i 或其他内容的线程中循环,并在另一个线程中循环检查 abs( y) 总是 >= abs(x)。每次测试创建和销毁两个线程的开销很大。

当然,要做到这一点,你必须知道如何使用C生成你想要的asm(或者直接写成asm)。