[英]C++11 atomic: why does this code work?
我们来看看这个结构:
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
在valid
标志上写入true
B
清除writing
标志 我希望这是不可能的,但是当谈到实际回答“为什么不可能?”的问题时,我不确定答案。 这是我的想法。
再次引用标准(29.3.12):
原子读 - 修改 - 写操作应始终读取与读 - 修改 - 写操作相关的写操作之前写入的最后一个值(按修改顺序)。
atomic_flag::test_and_set()
是一个原子读 - 修改 - 写操作,如29.7.5中所述。
由于atomic_flag::test_and_set()
总是读取一个“新值”,并且我用std::memory_order_acquire
内存排序调用它, 然后我无法读取valid
标志的陈旧值,因为我必须看到所有的一面 -在atomic_flag::clear()
调用之前由A
引起的效果(使用std::memory_order_release
)。
我对么?
澄清 。 我的整个推理( 错误或正确 )依赖于29.3.12。 对于我到目前为止所理解的,如果我们忽略atomic_flag
,即使它是atomic
也可以从valid
读取陈旧数据。 atomic
似乎并不意味着每个线程“总是立即可见”。 您可以要求的最大保证是您读取的值中的一致顺序,但您仍然可以在获取新数据之前读取过时数据。 幸运的是, atomic_flag::test_and_set()
和每个exchange
操作都有这个关键特性:它们总是读取新数据。 因此,只有在writing
标志上获取/释放(不仅仅是valid
),才能获得预期的行为。 你看到了我的观点(正确与否)?
编辑 :我的原始问题包括以下几行,如果与问题的核心相比得到了太多关注。 我让他们与已经给出的答案保持一致,但如果你现在正在阅读这个问题,请忽略它们。
valid
是atomic<bool>
而不是普通的bool
是否有任何意义?而且,如果它应该是一个atomic<bool>
,那么它的“最小”内存排序约束是什么?
在else
分支内部, valid
应该受到waiting
操作强加的获取/释放语义的保护。 然而,这并没有忘记制作valid
的原子的需要:
您忘记在分析中包含第一行( if (e.valid)
)。 如果valid
是bool
而不是atomic<bool>
此访问将完全不受保护。 因此,在完全写入/显示payload
之前,您可能会遇到其他线程可见更改valid
的情况。 这意味着线程B
可以将e.valid
计算为true
,并在payload
未完全写入时输入do something with e.payload
分支。
除此之外,您的分析似乎有些合理,但对我来说并不完全正确。 记忆顺序要记住的事情是获取和释放语义将配对。 在对同一版本的获取操作读取修改值之后,可以安全地读取在释放操作之前写入的所有内容。 考虑到这一点, waiting.clear(...)
上的发布语义确保当writing.test_and_set(...)
上的循环退出时,写入valid
必须是可见的,因为后者读取等待的更改(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约束:对于商店的加载和释放的获取应该可以完成这项工作,但是根据您的目标硬件,您可能仍然保持顺序一致性。 对于那些语义之间的区别,请看这里 。
第29.3.12节与此代码正确或不正确的原因无关。 您想要的部分( 在线可用标准的草案版本 )是第1.10节:“多线程执行和数据竞争”。 第1.10节定义了原子操作和原子操作非原子操作的先发生关系。
第1.10节说如果有两个非原子操作,你无法确定之前发生的关系,那么你就有了数据竞争。 它进一步声明(第21段)任何具有数据争用的程序都有未定义的行为。
如果e.valid
不是原子的,那么您在第一行代码和行e.valid=true
之间会有数据竞争。 因此,关于else
子句中的行为的所有推理都是不正确的(程序没有定义的行为,所以没有任何理由可以解释。)
在另一方面,如果所有的访问,以e.valid
是由原子操作上保护e.writing
(比如如果else
条款是你的整个程序),那么你的推理是正确的。 列表中的事件9不可能发生。 但原因不是第29.3.12节,而是第1.10节,它表示如果没有数据条,你的非原子操作似乎会顺序一致。
您正在使用的模式被称为双重检查锁定 。 在C ++ 11之前,无法实现双重检查锁定。 在C ++ 11中,您可以正确且可移植地进行双重检查锁定。 你这样做的方式是声明valid
是atomic
。
如果valid
不是原子那么的初始读取e.valid
与分配的第一行冲突e.valid
。
在其中一个线程获得自旋锁之前,无法保证两个线程都已经完成了读取,即步骤1和6未被排序。
e.valid的商店需要发布,条件中的负载需要获取。 否则,编译器/处理器可以自由地在写入有效载荷之前设置e.valid。 有一个开源工具CDSChecker,用于根据C / C ++ 11内存模型验证这样的代码。
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.