让我们来看看这个结构:
struct entry {
atomic<bool> valid;
atomic_flag writing;
char payload[128];
}
两个踏板A和B以这种方式同时访问此结构(让e
成为entry
的实例):
if (e.valid) {
// do something with e.payload...
} else {
while (e.writing.test_and_set(std::memory_order_acquire));
if (!e.valid) {
// write e.payload one byte at a time
// (the payload written by A may be different from the payload written by B)
e.valid = true;
e.writing.clear(std::memory_order_release);
}
}
我猜这段代码是正确的并且没有出现问题,但我想了解它为何有效。
引用C ++标准(29.3.13):
实现应该使原子库对原子载荷可见 在合理的时间内。
现在,考虑到这一点,想象线程A和B都进入else
块。这种交错可能吗?
A
和B
都会进入else
分支,因为valid
是false
A
设置writing
标志B
开始锁定writing
旗帜A
读取valid
标记(false
)并输入if
块A
写入有效负载A
在有效标记上写true
;很明显,如果A
再次阅读valid
,则会显示true
A
清除writing
标志B
设置writing
标志B
读取有效标记(false
)的陈旧值并输入if
块 B
写入其有效负载B
在true
标志valid
B
清除writing
标志我希望这是不可能的,但是当谈到实际回答“为什么不可能?”这个问题时,我不确定答案。这是我的想法。
再次引用标准(29.3.12):
原子读 - 修改 - 写操作应始终读取最后一个值 (在修改顺序中)写入与写入相关联之前 读 - 修改 - 写操作。
atomic_flag::test_and_set()
是一个原子读 - 修改 - 写操作,如29.7.5中所述。
由于atomic_flag::test_and_set()
总是读取“新值”,而我用std::memory_order_acquire
内存排序调用它,然后我无法读取{的陈旧值{1}}标记,因为我必须在valid
调用之前看到A
引起的所有副作用(使用atomic_flag::clear()
)。
我说错了吗?
澄清即可。我的整个推理(错误或正确)依赖于29.3.12。就我目前所理解的情况而言,如果我们忽略std::memory_order_release
,即使atomic_flag
,也可以从valid
读取陈旧数据。 atomic
似乎并不意味着“始终立即可见”每个线程。您可以要求的最大保证是您读取的值中的一致顺序,但您仍然可以在获取新数据之前读取过时数据。幸运的是,atomic
和每个atomic_flag::test_and_set()
操作都具有以下关键特性:它们始终读取新数据。因此,只有在exchange
标志上获取/释放(不仅仅在writing
上),您才能获得预期的行为。你看到了我的观点(正确与否)?
编辑:我的原始问题包括以下几行,与问题的核心相比,引起了太多关注。我留下他们与已经给出的答案保持一致,但如果你现在正在阅读这个问题,请忽略它们。
是否有任何意义valid
中有valid
和不是简单atomic<bool>
?此外,如果它应该是bool
,什么是它不会出现的'最小'内存排序约束问题?
答案 0 :(得分:5)
else
分支valid
内部应受waiting
上的操作强加的获取/释放语义的保护。然而,这并没有忘记使valid
成为原子的需要:
您忘记在分析中包含第一行(if (e.valid)
)。如果valid
是bool
而不是atomic<bool>
,则此访问权限将完全不受保护。因此,在valid
完全写入/可见之前,您可能会遇到payload
的更改对其他线程可见的情况。这意味着线程B
可以评估e.valid
到true
并输入do something with e.payload
分支,而payload
尚未完全编写。
除此之外,您的分析似乎有些合理,但对我来说并不完全正确。记忆顺序要记住的事情是获取和释放语义将配对。在对同一版本的获取操作读取修改值之后,可以安全地读取在释放操作之前写入的所有内容。考虑到这一点,waiting.clear(...)
上的发布语义确保在valid
上的循环退出时必须显示对writing.test_and_set(...)
的写入,因为后者会读取等待(the write done in
的更改waiting.clear(...)`)具有获取语义,并且在该变化可见之前不会退出。
关于§29.3.12:它与您的代码的正确性有关,但与阅读过时的valid
标志无关。你不能在clear之前设置标志,所以获取 - 释放语义将确保那里的正确性。 §29.3.12保护您免受以下情况的影响:
- A和B都进入else分支,因为valid为false
- A设置书写标志
- B看到写作的陈旧价值,并设置它
- A和B都读取有效标志(为假),输入if块并写入有效负载创建竞争条件
醇>
编辑:对于最小的Ordering约束:对于存储的加载和释放的获取应该可以完成这项工作,但是根据您的目标硬件,您可能仍然保持顺序一致性。对于这些语义之间的区别,请查看here。
答案 1 :(得分:2)
第29.3.12节与此代码正确或不正确的原因无关。您想要的部分(在the draft version of the standard available online中)是第1.10节:“多线程执行和数据争用”。 1.10节定义了原子操作的先发生关系,以及与原子操作有关的非原子操作。
1.10节说如果有两个非原子操作,你无法确定之前发生的关系,那么你就有了数据竞争。它进一步声明(第21段)任何具有数据竞争的程序都有未定义的行为。
如果e.valid
不是原子的,那么您在第一行代码和行e.valid=true
之间会有数据竞争。因此,关于else
子句中的行为的所有推理都是错误的(程序没有定义的行为,所以没有任何理由可以解释。)
另一方面,如果e.valid
的所有访问都受到e.writing
上的原子操作的保护(就像else
子句是整个程序一样)那么你的推理是正确的。列表中的事件9不可能发生。但原因不是第29.3.12节,而是第1.10节,它表示如果没有数据条,你的非原子操作将出现顺序一致。
您使用的模式称为double checked locking。在C ++ 11之前,无法实现双重检查锁定。在C ++ 11中,您可以正确且可移植地进行双重检查锁定。您这样做的方法是将valid
声明为atomic
。
答案 2 :(得分:1)
如果valid
不是原子的,那么第一行的e.valid
的初始读数与e.valid
的作业相冲突。
在其中一个线程获得自旋锁之前,不能保证两个线程都已经完成了读取,即步骤1和6没有被排序。
答案 3 :(得分:1)
e.valid的商店需要发布,条件中的负载需要获取。否则,编译器/处理器可以自由地在写入有效载荷之前设置e.valid。 有一个开源工具CDSChecker,用于根据C / C ++ 11内存模型验证这样的代码。