考虑这个例子:

#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#30

原子读取-修改-写入操作应始终读取在与读取-修改-写入操作相关的写入之前写入的最后一个值(按照修改顺序)。

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#300

推荐做法:实现应该使原子存储对原子加载可见,并且原子加载应该在合理的时间内观察原子存储。

从实现的角度来看,存在一个时间滞后,使得存储在合理的时间内#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

  • 我不知道标准到底说了什么,但我发现这个视频很有趣:


    – 

  • @HolyBlackCat 如果 处的断言#2失败,则意味着其读取值为0,这意味着#3无法读取0,标志未设置为true, 处的循环#1不可能存在,#2不会执行。嗯,这里有一些悖论。


    – 

  • @HolyBlackCat 嗯,[atomics.order] p10 不是这个要求吗?如果#2读取0和写入2,则意味着0立即在之前2,以及,如果#3读取0和写入1,则0立即在之前1,因为 mod 顺序是全序,要么1在之前2,要么2在之前1,后者的写入值违反了该规则。


    – 

  • 是的,你说得对。我同意断言总是有效的。


    – 


  • 这是否意味着 RMW 比非 RMW 读取更不容易读取过时的值? – 我要说多少次“过时”不是一个有用的概念。尤其是对于一般情况的定义。所以任何从未修改过的变量都会自动过时?或者如果在加载之前发生一次修改,它就是过时的?但只要有多个这样的修改,读取除第一个之外的任何修改都是非过时的?


    – 


最佳答案
2

exchange如果该程序中发生两个操作,则其中一个必须返回 0,另一个必须返回 1,这是正确的。这意味着断言永远不会失败。

这是否意味着 RMW 比非 RMW 读取更不容易读取陈旧的值,至少在这个例子中是这样?

不存在过时值。为了解释过时值应该是什么,您必须说“与修改顺序中的条目相对应的值,使得修改顺序中后面的条目已经存在”,但为了解释“已经”的意思,您必须假设所有线程都具有某种线性时间顺序,而原子变量的工作方式并非如此。

标准表达的方式是正确的:即原子 RMW 操作读取的值始终是从 RMW 操作创建的条目之前的修改顺序中的条目中获取的值。原子加载操作不可能具有此属性,因为它不会按修改顺序创建任何条目。

4

  • 我给出了“过时”值,这意味着修改顺序中后一个副作用比前一个副作用更新。在此示例中,#2或仅在读取和创建#2'时执行,因此可以知道修改顺序中先于,在这种情况下,如果值为,则加载读取“过时”值#101010


    – 


  • 由于“陈旧”在这种情况下是一个模棱两可的措辞,我删除了该问题并将其改为:这是否意味着 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可以看到存储,只保证最终看到存储时的顺序。


    –