我正在写一些无锁代码,我想出了一个有趣的模式,但我不确定它是否会在放松的内存排序下表现得如预期。
解释它的最简单方法是使用一个例子:
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
。
显然,在执行此操作的线程上,a
和b
的加载必须在评估if
条件之前完成。
同样,c
上的读 - 修改 - 写(RMW)操作必须在评估条件后完成(因为它以条件为条件)。
我想知道的是,此代码是否保证c_local
的值至少与a_local
和b_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?
另一次更新:在阅读了目前为止的所有回复并自行梳理标准之后,我认为只能使用标准来证明代码是正确的。那么,任何人都可以提出一个符合标准的理论体系的反例,并且还会触发断言吗?
答案 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_lock
和shared_ptr::count
)对于不按顺序执行的那些功能的另一种语义,您还必须承认根本没有程序可以被证明是正确的,因为非原子操作没有“顺序”,并且需要它们来构造和销毁变量
或者,您不再将标准文本作为该语言的唯一单词,而是使用一些常识,这是始终推荐的做法。