【问题标题】:reordering atomic operations in C++在 C++ 中重新排序原子操作
【发布时间】:2017-03-12 05:48:23
【问题描述】:

假设我有 2 个线程:

int value = 0;
std::atomic<bool> ready = false;

thread 1:
value = 1
ready = true;

thread 2:
while (!ready);
std::cout << value;

这个程序能输出0吗?

我阅读了有关 C++ 内存模型的信息——特别是顺序一致性,我认为这是默认的,但并不是特别清楚。编译器是否只需要将原子操作相对于彼此以正确的顺序放置,还是需要将原子操作相对于所有其他操作以正确的顺序放置?

【问题讨论】:

  • 比这复杂一点:需要考虑 in-threadbetween-threads 顺序。基本上,这些规则“按预期”工作,因此代码是正确的,并且按照您的想法执行。
  • 为了顺序一致性,它很像一个屏障;它上面的东西不能重新排列在后面,下面的东西不能重新排列在它前面。其他值是否是原子的都没关系。在这种情况下,你很好。
  • @ShadowRanger C/C++ 中没有“重排”的概念。
  • @curiousguy:我为使用同义词道歉?我不知道你在这里找什么。
  • @ShadowRanger 的同义词是什么?什么标准术语是“重新排列”的 syn 并用于定义 C/C++ MT 语义?我只是说您无法根据可重新排列的代码进行推理。 C/C++ 不能那样工作。

标签: c++ multithreading memory-model stdatomic


【解决方案1】:

默认情况下,对原子变量的操作是使用memory_order_seq_cst 语义完成的,这保证不会进行重新排序。

因此,value = 1 行不能在原子赋值:value = 1 下面重新排序,因此std::cout &lt;&lt; value; 行将始终打印 1。

按照同样的规则,std::cout &lt;&lt; value; 行不能重新排序
线上:while (!ready);

【讨论】:

    【解决方案2】:

    根据 ShadowRanger 的响应,它就像一个内存屏障。 但是,有关它为什么这样做的更多详细信息,我建议查看Herb Sutter's talk on atomic weapons。他非常详细地介绍了原子的工作方式和原因。

    【讨论】:

      【解决方案3】:

      这个程序能输出0吗?

      你的推理是正确的。在 ISO C++ 中,seq_cst 和 acq_rel atomics 可以在线程之间创建happens-before/after 关系,使一个线程可以安全地写入非原子变量,然后另一个线程在没有数据竞争 UB 的情况下读取它。

      在这种情况下,您已经正确地这样做了:spin-wait 循环是来自标志的 seq-cst 加载,只有在看到 true 值时才退出循环。非原子value 的评估发生在看到true 的负载之后。而在编写器中,顺序发布存储确保在值存储之前看不到标志存储。


      在为普通 ISA 编译为 asm 时,编译器必须在发布存储之前尊重非原子存储的顺序,以及在获取加载之后的非原子加载的顺序。除非它可以以某种方式证明仍然会有任何其他可能的线程观察到这一点。

      【讨论】:

        【解决方案4】:

        是的,但这只是因为您使用了默认值。

        Cst 受到影响,因为它使用全局范围进行重新排序;这是旧架构的产物。较新的架构具有更细粒度的排序范围,因此您可能期望标准更新会在不久的将来使您的代码失效。

        写入队列最终可能包含数十个条目,每个条目的解析延迟都非常大。将这些划分为重要的和不重要的是显而易见的一步,新架构已经体现了这一点。

        C++标准创建委员会显然已经超出了他们的深度,应该停止发明无用的废话。

        【讨论】:

        • 1) 不确定您在说什么“旧架构的产物”。 2)Q中的代码只依赖于写即释放,读即获得保证。 3)哪个标准更改可能会破坏该代码?标准委员会为什么要做出这样的改变?
        • 看看像 risc-v 这样的东西。排序/一致性保证可以基于每个字(真正的高速缓存行),并基于指令本身。现代 x86 上的屏障可能会导致等待 64 条缓存线,这些都与同步本身无关。例如,一整堆堆栈操作,几乎可以肯定是线程本地的,然后是一些不相关 var 的原子 incr,将停止 incr,直到所有这些堆栈写入全局可见。对于你真正想要全局的情况,有正常的全局障碍。
        • (2, 3) C++ 的评论可能有点讽刺,但标准发明委员会似乎并不十分重视兼容性;考虑到 standards 事情,这很奇怪。
        • 您对 C++ 委员会的评论措辞强硬,但我认为 mine 更强大,并且也涉及 C 和 C++ 委员会。
        • ISO C++ 确实有 mo_consume 用于加载和另一个依赖加载之间的依赖排序。问题是编译器在发现问题后(暂时)放弃了实现它,目前将其加强到acquire。我对RISC-V并不特别熟悉;除了大多数弱排序的 ISA 提供的通常的依赖排序之外,它还有其他东西吗?比如可能只关心某些商店而不是所有排队商店的 StoreLoad 屏障?
        【解决方案5】:

        第一个注意事项:绝对不可能在任何支持线程的 C 和 C++ 版本中推理任何 C 或 C++ 程序,因为存在 UB(未定义行为)的可能性,因为没有明确定义的抽象线程语义,或者任何语义都已定义。这是 C 和 C++ 语义中另一个主要的理论和实践缺陷(在许多其他严重缺陷之上)。

        但您可以从实际角度进行推理:编译器在线程原语的实现中是非常可预测的(将来可能不会是这种情况,因为编译器编写者会熟练掌握线程语义并开始使用 UB 声明来破坏事物)。

        当使用线程通信原语时,编译器会做正确的事情来保证信息流。 while (!ready); 保证线程退出循环原子对象被设置:有一个明确定义的“过去”。

        作为一个定义不明确的“过去”的实际现实世界示例,请记住阿波罗与宇航员在休斯顿的谈话中的音频交流:没有明确的概念来确定谁先开口说话,因为宇航员离得很远,而且唯一的我们(来自休斯顿)的录音显示了一个订单,但来自宇宙飞船的假设录音会显示另一个,而且这两个订单都不是正确的。宇航员和休斯顿开始毫无秩序地交谈,其他人过去都没有。在你看到这一点之前,你不能声称自己理解相对论。

        使用多线程,您可以进行不属于其他人过去的内存操作,并且不知道如果他们尝试使用非过去操作的对象会观察到什么。

        【讨论】:

        • 这里没有UB:非原子变量value的同时读/写被旋转等待循环排除。
        • 无论如何,回到您不能在 OP 程序中排除 UB 的声明:请举例说明导致 UB 的 C++ 抽象机允许的事物排序。或者,如果您需要考虑一些实现细节,那么这里有一个 UB 是如何发生的示例。假设线程以std::thread 等开头,对我来说似乎很明显可以排除UB。您是否忘记了 C++11 在语言标准中引入了线程和多线程内存模型,不再将这些东西作为非标准化的扩展?
        • UB 只会破坏任何如果它发生在任何线程中的实际执行路径上。例如,if(y) x=/y; 是安全的,因为除以零的 UB 由检查保护。为了销毁一个对象,确保对象的所有使用都发生在析构函数之前是完全正常的。除非你的程序有释放后使用的错误,否则没有问题存在。语言不能保护您免受该错误的影响这一事实并不意味着无法避免。
        • 这是不正确的。如果只有一个线程读/写数据结构,它可以是正常的非原子的,没有任何问题。如果您希望将一个线程的写入发布到另一个线程(例如在您的链接问题中),您只需要原子或互斥体来创建同步(或使所有数据原子)。对于导致 UB 的问题中的代码,您仍然没有显示 C++ 抽象机器操作的特定顺序,这是逻辑和标准允许的。我认为您缺少有关 C++ 内存排序和 UB 规则的基本知识。
        • 我更仔细地查看了您之前链接的What formally guarantees that reads don't see a write (with race condition) in other threads?。我在此线程的早些时候错过了两个商店都落后于错误条件的事实,因此没有明显的 UB。那里的答案具有误导性。事实上,我认为那里根本没有 UB。我发布了一个答案。如果您的推理基于该 Q 的先前答案,难怪您在这里的主张如此错误并且与现实脱节。是的,您确实需要显示操作顺序!
        猜你喜欢
        • 2017-11-25
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2013-03-08
        • 1970-01-01
        • 2021-05-13
        • 1970-01-01
        相关资源
        最近更新 更多