在这个角落的情况下,C ++ 11内存排序保证是什么?

时间:2013-08-14 04:22:17

标签: multithreading c++11 atomic memory-model

我正在写一些无锁代码,我想出了一个有趣的模式,但我不确定它是否会在放松的内存排序下表现得如预期。

解释它的最简单方法是使用一个例子:

std::atomic<int> a, b, c;

auto a_local = a.load(std::memory_order_relaxed);
auto b_local = b.load(std::memory_order_relaxed);
if (a_local < b_local) {
    auto c_local = c.fetch_add(1, std::memory_order_relaxed);
}

请注意,所有操作都使用std::memory_order_relaxed

显然,在执行此操作的线程上,ab的加载必须在评估if条件之前完成。

同样,c上的读 - 修改 - 写(RMW)操作必须在评估条件后完成(因为它以条件为条件)。

我想知道的是,此代码是否保证c_local的值至少与a_localb_local的值保持同步?如果是这样,如果放宽内存排序,这怎么可能?控制依赖是否与RWM操作一起充当某种获取范围? (请注意,在任何地方都没有相应的版本。)

如果上述情况属实,我相信这个例子也应该有效(假设没有溢出) - 我是对的吗?

std::atomic<int> a(0), b(0);

// Thread 1
while (true) {
    auto a_local = a.fetch_add(1, std::memory_order_relaxed);
    if (a_local >= 0) {    // Always true at runtime
        b.fetch_add(1, std::memory_order_relaxed);
    }
}

// Thread 2
auto b_local = b.load(std::memory_order_relaxed);
if (b_local < 777) {
    // Note that fetch_add returns the pre-incrementation value
    auto a_local = a.fetch_add(1, std::memory_order_relaxed);
    assert(b_local <= a_local);    // Is this guaranteed?
}

在线程1上,存在一个控制依赖关系,我怀疑a总是在b递增之前递增(但它们各自保持增加的颈部和颈部)。在线程2上,还有另一个控件依赖项,我怀疑在b递增之前保证b_local被加载到a。我还认为fetch_add返回的值至少与b_local中的任何观察值一样近,因此assert应保持不变。但我不确定,因为这与通常的内存排序示例有很大不同,而且我对C ++ 11内存模型的理解并不完美(我无法在任何程度的确定性下推理这些内存排序效果)。任何见解都将不胜感激!


更新:正如bames53在评论中有所指出,给定一个足够智能的编译器,if可能在适当的情况下完全优化,在这种情况下放松的负载可以在RMW之后重新排序,导致它们的值比fetch_add返回值更新(assert可以在我的第二个示例中触发)。但是,如果不是if而是插入atomic_signal_fence(不是atomic_thread_fence),该怎么办?无论做了什么优化,编译器当然都不能忽略它,但它确保代码的行为符合预期吗?在这种情况下,CPU是否允许进行任何重新排序?

然后第二个例子变为:

std::atomic<int> a(0), b(0);

// Thread 1
while (true) {
    auto a_local = a.fetch_add(1, std::memory_order_relaxed);
    std::atomic_signal_fence(std::memory_order_acq_rel);
    b.fetch_add(1, std::memory_order_relaxed);
}

// Thread 2
auto b_local = b.load(std::memory_order_relaxed);
std::atomic_signal_fence(std::memory_order_acq_rel);
// Note that fetch_add returns the pre-incrementation value
auto a_local = a.fetch_add(1, std::memory_order_relaxed);
assert(b_local <= a_local);    // Is this guaranteed?

另一次更新:在阅读了目前为止的所有回复并自行梳理标准之后,我认为只能使用标准来证明代码是正确的。那么,任何人都可以提出一个符合标准的理论体系的反例,并且还会触发断言吗?

3 个答案:

答案 0 :(得分:4)

信号围栏没有提供必要的保证(好吧,除非“线程2”是实际在“线程1”上运行的信号处理者)。

为了保证正确的行为,我们需要线程之间的同步,并且执行该操作的围栏是std::atomic_thread_fence


让我们标记语句,以便我们可以绘制各种执行图(根据需要使用线程围栏替换信号围栏):

while (true) {
    auto a_local = a.fetch_add(1, std::memory_order_relaxed); // A
    std::atomic_thread_fence(std::memory_order_acq_rel);      // B
    b.fetch_add(1, std::memory_order_relaxed);                // C
}


auto b_local = b.load(std::memory_order_relaxed);             // X
std::atomic_thread_fence(std::memory_order_acq_rel);          // Y
auto a_local = a.fetch_add(1, std::memory_order_relaxed);     // Z


首先让我们假设 X 加载 C 写的值。以下段落指定在这种情况下,围栏同步,并且发生在关系建立之前。

29.8 / 2:

  

如果存在原子操作 X Y ,则发布范围 A 与获取范围 B 同步,两者都在某个原子对象 M 上运行,这样 A X 之前排序, X 修改 M Y B 之前排序, Y 读取 X 写入的值或值如果它是一个释放操作,那么在假设的释放序列 X 中的任何副作用都会被写入。

这是一个可能的执行顺序,其中箭头发生在关系之前。

Thread 1: A₁ → B₁ → C₁ → A₂ → B₂ → C₂ → ...
                ↘
Thread 2:    X → Y → Z
  

如果原子对象 M 上的副作用 X 发生在 M 的值计算 B 之前,那么评估 B 应该从 X 中取值,或从修改后的 X 中的副作用 Y 取值 M 的顺序。 - [C ++ 11 1.10 / 18]

因此 Z 的负载必须从 A 1 或后续修改中获取其值。因此断言成立,因为在 A 1 和所有后来的修改中写入的值大于或等于在 C 1 处写入的值(并由 X读取)。


现在让我们看看栅栏不同步的情况。当b的加载未加载由线程1写入的值,而是读取b初始化的值时,会发生这种情况。虽然线程仍然存在同步:

30.3.1.2/5

  

同步:构造函数调用的完成与f副本的调用开始同步。

这是指定std::thread的构造函数的行为。所以(假设在a初始化之后线程创建正确排序) Z 读取的值必须从a的初始化或后续的一个中获取其值修改线程1,这意味着断言仍然存在。

答案 1 :(得分:3)

这个例子得到了一种从薄空中读取行为的变化。规范中的相关讨论见第29.3p9-11节。由于当前版本的C11标准不能保证依赖性得到尊重,因此内存模型应该允许触发断言。最可能的情况是编译器优化了a_local&gt; = 0的检查。但即使您用信号围栏替换该检查,CPU也可以自由重新排序这些指令。 您可以使用开源CDSChecker工具在C / C ++ 11内存模型下测试此类代码示例。 你的例子的有趣问题是,对于违反断言的执行,必须有一个依赖循环。更具体地说:

由于if条件,线程1中的b.fetch_add依赖于同一循环迭代中的a.fetch_add。线程2中的a.fetch_add取决于b.load。对于断言违规,我们必须在晚于T2的a.fetch_add循环迭代中从b.fetch_add读取T2的b.load。现在考虑b.load读取的b.fetch_add并将其称为#以供将来参考。我们知道b.load依赖于#,因为它从#。

获取它的价值

我们知道#必须依赖于T2的a.fetch_add,因为T2的a.fetch_add原子在与#相同的循环迭代中从T1读取并更新先前的a.fetch_add。所以我们知道#取决于线程2中的a.fetch_add。这给了我们一个依赖的循环,并且很简单,但C / C ++内存模型允许。实际产生该循环的最可能方式是(1)编译器确定a.local总是大于0,从而打破了依赖性。然后它可以循环展开并重新排序T1的fetch_add,但是它想要。

答案 2 :(得分:0)

  

阅读了到目前为止的所有答案并梳理了   我自己是标准的,我认为无法证明代码是   仅使用标准即可。

并且除非您承认非原子操作在魔术上更安全且更有序,否则放松的原子操作(这是愚蠢的),并且C ++的一种语义没有原子(以及try_lockshared_ptr::count)对于不按顺序执行的那些功能的另一种语义,您还必须承认根本没有程序可以被证明是正确的,因为非原子操作没有“顺序”,并且需要它们来构造和销毁变量

或者,您不再将标准文本作为该语言的唯一单词,而是使用一些常识,这是始终推荐的做法。