考虑这个例子:
#include <iostream>
#include <atomic>
#include <thread>
#include <chrono>
#include <cassert>
int main(){
std::atomic<int> v = 0;
std::atomic<bool> flag = false;
std::thread t1([&](){
while(!flag.load(std::memory_order::relaxed)){} // #1
assert(v.exchange(2,std::memory_order::relaxed) == 1); // #2
});
std::thread t2([&](){
if(v.exchange(1,std::memory_order::relaxed) == 0){ // #3
flag.store(true, std::memory_order::relaxed); // #4
}
});
t1.join();
t2.join();
}
#1
在这个例子中,只有当#4
设置flag
为 时,处的循环才会退出,而只有当 RMW 操作的读取部分读取时,true
标志才会设置为。由于 RMW 操作读取部分的要求是 [atomics.order] p10true
#3
0
原子读取-修改-写入操作应始终读取在与读取-修改-写入操作相关的写入之前写入的最后一个值(按照修改顺序)。
0
这意味着,如果 RMW 操作#3
读取,则其他 RMW 操作都无法读取该值0
。换句话说,如果#2
可以读取0
和写入2
,#3
则不会读取0
也#4
不会执行。换句话说,如果所有其他操作也是 RMW 操作,则读取-修改-写入操作的读取值由该操作唯一拥有(这是自旋锁如何工作的本质)。
所以,Q1 是:意志断言#2
永远不会失败,对吗?
但是如果#2
改为纯负载的话,就会是这样的:
assert(v.load(std::memory_order::relaxed) == 1); // #2'
根据 [intro.races] p18
如果原子对象 M 上的副作用 X 发生在 M 的值计算 B 之前,则评估 B 将从 X 中获取其值,或者从按 M 的修改顺序紧随 X 之后的副作用 Y 中获取其值。
之前发生的副作用#2'
只是初始值,尽管存储在的副作用0
按照修改顺序排列,但纯负载仍然可以读取,因为 [intro.races] p18 使用了“或”,这也是 [atomics.order] p11 所暗示的1
#3
0
0
推荐做法:实现应该使原子存储对原子加载可见,并且原子加载应该在合理的时间内观察原子存储。
从实现的角度来看,存在一个时间滞后,使得存储在合理的时间内#3
不可见。从 C++ 标准的角度来看,这个结果也是由 [intro.races] p18 中的“或”所暗示的。#2'
Q2:
如果将 处的 RMW 操作#2
改为像 这样的纯加载#2'
,断言就会失败,对吗?
Q3:
如果#2'
可以失败并且#2
永远不会失败这是否意味着 RMW 比非 RMW 读取更不容易读取陈旧的值,至少在这个例子中是这样?,这是否意味着RMW
比非 RMW 读取更容易按照修改顺序读取后者的修改,至少在这个例子中是这样?
添加:
我认为编译器#2
无法对其进行重新排序,因为类似于自旋锁中失败的 CAS(即纯加载,内存顺序宽松),如果存在这种重新排序,自旋锁将无法正常工作。此外,由于断言,本例中编译器的任何重新排序都是可观察到的。但是,从内存顺序的角度来看,在之前不会发生,反之亦然,理论上可能会失败。我不确定。但是,此示例取决于执行的逻辑顺序,任何对顺序的破坏都是可观察到的。#1
#2
#3
#2
#3
笔记:
这是一个后续问题,,它的例子不清楚,假设也难以理解,而这个问题得到了改进和澄清。
7
最佳答案
2
exchange
如果该程序中发生两个操作,则其中一个必须返回 0,另一个必须返回 1,这是正确的。这意味着断言永远不会失败。
这是否意味着 RMW 比非 RMW 读取更不容易读取陈旧的值,至少在这个例子中是这样?
不存在过时值。为了解释过时值应该是什么,您必须说“与修改顺序中的条目相对应的值,使得修改顺序中后面的条目已经存在”,但为了解释“已经”的意思,您必须假设所有线程都具有某种线性时间顺序,而原子变量的工作方式并非如此。
标准表达的方式是正确的:即原子 RMW 操作读取的值始终是从 RMW 操作创建的条目之前的修改顺序中的条目中获取的值。原子加载操作不可能具有此属性,因为它不会按修改顺序创建任何条目。
4
-
我给出了“过时”值,这意味着修改顺序中后一个副作用比前一个副作用更新。在此示例中,
#2
或仅在读取和创建#2'
时执行,因此可以知道修改顺序中先于,在这种情况下,如果值为,则加载读取“过时”值。#1
0
1
0
1
0
–
-
由于“陈旧”在这种情况下是一个模棱两可的措辞,我删除了该问题并将其改为:这是否意味着 RMW 比非 RMW 读取更倾向于按修改顺序读取后者的修改,至少在这个例子中是这样?
–
-
@xmh0511 在这个例子中,RMW 只能读取 1,而在另一个程序中,如果您有一个纯负载,负载可以读取 0。但您正在比较两个不同的程序执行,因此比较它们的修改顺序是没有意义的。
– -
您的意思
assert(v.exchange(0,std::memory_order::relaxed) == 1);
是等效过渡到纯负载吗?
–
|
删除了错误的答案,但我认为这些评论可能仍然对其他人有帮助。
7
-
1这个问题不是关于如何编写好的或合理的代码,而是关于内存模型的一个棘手细节。您说得对,#2 可能会读取 a
0
,例如使用编译时重新排序,因此v.exchange
在可能无限的循环之前完成,但在这种情况下,t2 无法读取 a ,0
因此永远不会存储 flag=true,因此自旋等待循环永远不会终止,永远不会达到实际的assert
。
– -
1指出了一些当前允许的原子性不良优化,但即使它也没有建议允许编译器在可能无限的循环中重新排序。(在这种情况下,自旋循环包括和
atomic
操作,因此允许它在没有 UB 的情况下无限运行。)但无论如何,即使有其他方法可以让 #2 潜在地执行并使其值对其他线程可见,而退出自旋循环仍然是推测性的,这也不会导致断言失败。
– -
1请注意,原子 RMW 仍使整个 RMW 原子化,而不仅仅是无撕裂加载和单独的无撕裂存储。如果您的主张基于此,请查看问题引用的 [atomics.order] p10(或在上查看)。它适用于所有原子 RMW,例如
v.exchange
,甚至包括relaxed
。
– -
2是的,符合技术要求的实现可以忽略“应该”要求,即存储在“合理”和“有限”的时间内对加载可见,因此自旋循环永远不会退出。或者,即使 #2 交换是在无限循环之后,也可能有理由让 #3 交换在 #2 交换之前发生。您的答案似乎仍然没有回答语言律师的问题。
– -
1对变量的宽松存储不需要及时提供给其他线程(尽管建议这样做) -为什么要单独列出宽松存储?重要的是顺序,而不是及时性。标准不保证其他线程多久
seq_cst
可以看到存储,只保证最终看到存储时的顺序。
–
|
–
#2
失败,则意味着其读取值为0
,这意味着#3
无法读取0
,标志未设置为true
, 处的循环#1
不可能存在,#2
不会执行。嗯,这里有一些悖论。–
#2
读取0
和写入2
,则意味着0
立即在之前2
,以及,如果#3
读取0
和写入1
,则0
立即在之前1
,因为 mod 顺序是全序,要么1
在之前2
,要么2
在之前1
,后者的写入值违反了该规则。–
–
–
|