是什么从形式上保证了非原子变量不会看到空气中的稀疏值,并且在理论上可以像原子弛豫一样创建数据竞争呢?

cur*_*guy 3 c++ multithreading language-lawyer stdatomic data-race

这是有关C ++标准的形式保证的问题。

该标准指出,std::memory_order_relaxed原子变量规则允许“凭空” /“出乎意料”的值出现。

但是对于非原子变量,这个例子可以有UB吗?是否r1 == r2 == 42有可能在C ++抽象机?== 42最初都不是变量,因此您不希望任何if主体执行,这意味着不会写入共享变量。

// Global state
int x = 0, y = 0;

// Thread 1:
r1 = x;
if (r1 == 42) y = r1;

// Thread 2:
r2 = y;
if (r2 == 42) x = 42;
Run Code Online (Sandbox Code Playgroud)

上面的示例改编自标准,该标准明确表示原子对象规范允许这种行为

[注意:在以下示例中,要求确实允许r1 == r2 == 42,而x和y最初为零:

// Thread 1:
r1 = x.load(memory_order_relaxed);
if (r1 == 42) y.store(r1, memory_order_relaxed);
// Thread 2:
r2 = y.load(memory_order_relaxed);
if (r2 == 42) x.store(42, memory_order_relaxed);
Run Code Online (Sandbox Code Playgroud)

但是,实现不应允许这种行为。–尾注]

所谓的“内存模型”的哪一部分保护非原子对象免于因看到空想值的读取而引起的这些相互作用


当比赛条件与存在不同的价值观xy什么保证了共享变量的是读(正常,非原子)不能看到这样的价值观?

被执行if机构不能创造导致数据争夺的自我实现条件吗?

eer*_*ika 8

当存在竞争条件时,可以保证读取共享变量(正常,非原子)看不到写入

没有这样的保证。

存在竞争条件时,程序的行为是不确定的:

[介绍种族]

如果两个动作可能同时发生

  • 它们由不同的线程执行,或者
  • 它们是无序列的,至少一个是由信号处理程序执行的,并且它们都不都是由相同的信号处理程序调用执行的。

如果一个程序的执行包含两个潜在的并发冲突操作,其中至少一个不是原子操作,并且没有一个先于另一个发生,则执行该程序将导致数据争用,除了以下所述的信号处理程序的特殊情况。任何此类数据争用都会导致不确定的行为。...

特殊情况下,是不是这个问题很重要,但我会包括它的完整性:

即使两次访问volatile std::sig_­atomic_­t都发生在同一线程中,即使两次或多次发生在同一线程中,对同一类型的同一对象的两次访问也不会导致数据争用。...

  • 小注:[数据争用和竞争条件不是同一回事](/sf/ask/789338161/上下文)。数据竞争是未定义的行为,竞争条件不是。在竞争情况下,未指定特定命令的顺序(在不同的运行中导致(可能)导致不同的结果),但是确实定义了行为。 (6认同)
  • @Omnifarious,几乎是信号处理程序与程序其余部分之间通信的唯一可移植方式。 (3认同)
  • @curiousguy大多数多线程程序使用互斥体或其他同步原语(或“ std :: atomic”类型)来保护共享数据。如果您不这样做,那么您的程序已损坏。 (3认同)
  • @curiousguy - 如果“x”和“y”确实是由多个线程访问的同一块内存,那么它们通常会,是的。一些非常仔细编写的无锁数据结构代码将以非常特定的方式使用多个原子变量,而不使用互斥体。但这是非常棘手的代码,很难编写并保持正确。在这种特殊情况下,如果您主要担心的是,如果在任一线程进入之前“x”和“y”都是“0”,那么它们都保持“0”,那么您可能只使用原子和更受约束的内存顺序。 (2认同)

Pet*_*des 6

您的问题的文本似乎缺少示例的要点和凭空而来的值。您的示例不包含数据竞争 UB。(它可能在这些线程运行之前x或被y设置为42,在这种情况下,所有赌注都关闭,引用数据竞争 UB 的其他答案适用。)

没有针对真实数据竞争的保护措施,只能针对凭空存在的值进行保护。

我认为您真的在问如何将该mo_relaxed示例与非原子变量的理智和明确定义的行为相协调。这就是这个答案所涵盖的内容。


该注释指出了原子mo_relaxed形式主义中的一个漏洞,而不是警告您对某些实现的真正可能影响。

这种差距(我认为)不适用于非原子对象,仅适用mo_relaxed.

他们说但是,实现不应该允许这种行为。– 尾注]。显然,标准委员会找不到正式化该要求的方法,所以现在它只是一个注释,但并不是可选的。

很明显,尽管这不是严格的规范,但 C++ 标准打算禁止宽松原子的凭空值(并且通常我假设)。后来的标准讨论,例如2018 年的 p0668r5:修订 C++ 内存模型(它没有“修复”这个,这是一个不相关的变化)包括多汁的侧节点,如:

我们仍然没有一种可接受的方法来使我们的非正式(自 C++14 起)禁止无中生有的结果精确。其主要的实际影响是使用宽松原子对 C++ 程序进行形式验证仍然不可行。上述论文提出了类似于http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2013/n3710.html的解决方案。我们继续忽略这里的问题......

所以是的,对于relaxed_atomic,标准的规范部分显然比非原子的要弱。这似乎是他们如何定义规则的一个不幸的副作用。

AFAIK 没有任何实现可以在现实生活中产生凭空产生的值。


标准短语的后续版本更清楚地说明了非正式建议,例如在当前草案中:https : //timsong-cpp.github.io/cppwp/atomics.order#8

  1. 实现应确保不会计算循环依赖于其自身计算的“凭空”值
    ...
  1. [注意:建议 [of 8.] 同样不允许r1 == r2 == 42在以下示例中,x 和 y 再次初始为零:

       // Thread 1:
       r1 = x.load(memory_order::relaxed);
       if (r1 == 42) y.store(42, memory_order::relaxed);
       // Thread 2:
       r2 = y.load(memory_order::relaxed);
       if (r2 == 42) x.store(42, memory_order::relaxed);
    
    Run Code Online (Sandbox Code Playgroud)

    — 尾注 ]


(这个答案的其余部分是在我确定标准也打算禁止这样做之前写的mo_relaxed。)

我敢肯定的C ++抽象机也不会允许r1 == r2 == 42
C++ 抽象机操作中所有可能的操作顺序都导致r1=r2=0没有 UB,甚至没有同步。因此该程序没有 UB,任何非零结果都将违反“as-if”规则

形式上,ISO C++ 允许实现以任何方式实现函数/程序,以提供与 C++ 抽象机相同的结果。对于多线程代码,实现可以选择一种可能的抽象机器排序并决定它是总是发生的排序。(例如,在为强有序 ISA 编译为 asm 时重新排序宽松的原子存储时。编写的标准甚至允许合并原子存储,但编译器选择不这样做)。 但是程序的结果总是必须是抽象机器可以产生的东西。(只有 Atomics 一章介绍了一个线程在没有互斥锁的情况下观察另一个线程的操作的可能性。否则,如果没有数据竞争 UB,这是不可能的)。

我认为其他答案对此没有足够仔细。(当它第一次发布时我也没有)。 不执行的代码不会导致 UB(包括数据竞争 UB),并且不允许编译器发明对对象的写入。(除了在已经代码路径无条件地写出来,像y = (x==42) ? 42 : y;明显创建数据竞争UB)。

对于任何非原子对象,如果不实际写入它,那么其他线程也可能正在读取它,而不管未执行if块中的代码如何。标准允许这样做,并且不允许变量在抽象机器尚未写入时突然读取为不同的值。(对于我们甚至不读取的对象,例如相邻的数组元素,另一个线程甚至可能正在写入它们。)

因此,我们不能做任何会让另一个线程暂时看到对象不同值的事情,或者阻止它的写入。发明对非原子对象的写入基本上总是一个编译器错误;这是众所周知的并且普遍同意的,因为它可以破坏不包含 UB 的代码(并且在实践中已经这样做了一些编译器错误的情况,例如 IA-64 GCC 我认为在一个破坏Linux内核的点)。IIRC,Herb Sutter 在他演讲的第 1 部分或第 2 部分“原子<> 武器:C++ 内存模型和现代硬件”中提到了这样的错误,他说在 C++11 之前它通常已经被认为是编译器错误,但是 C++ 11 对此进行了编纂,并使其更容易确定。

或者最近使用 ICC for x86 的另一个例子: icc 崩溃:编译器能否在抽象机器中不存在的地方发明写入?


在 C++ 抽象机中,无论分支条件的加载顺序或同时性如何,执行都无法达到y = r1;x = r2;x并且y都读为0并且没有一个线程写过它们。

不需要同步来避免 UB,因为抽象机器操作的顺序不会导致数据竞争。ISO C++ 标准对推测执行或当错误推测到达代码时会发生什么事情没有任何说明。那是因为推测是真实实现的特征,而不是抽象机器的特征。由实现(硬件供应商和编译器编写者)来确保遵守“as-if”规则。


在 C++ 中编写代码if (global_id == mine) shared_var = 123;并让所有线程执行它是合法的,只要最多一个线程实际运行该shared_var = 123;语句即可。(并且只要存在同步以避免非原子上的数据竞争int global_id)。如果事情像这样打破了,它就会发生混乱。例如,您显然可以得出错误的结论,例如在 C++ 中重新排序原子操作

观察到未发生非写入不是数据竞争 UB。

它也不是 UB 运行,if(i<SIZE) return arr[i];因为数组访问仅i在边界内发生。

我认为“出乎意料”的价值发明说明适用于松弛原子,显然是在原子一章中对它们的特殊警告。(即使这样,AFAIK 它实际上也不会发生在任何真正的 C++ 实现上,当然不是主流的实现。在这一点上,实现不必采取任何特殊措施来确保它不会发生在非原子变量上。 )

我不知道在标准的原子章节之外有任何类似的语言允许实现允许值像这样突然出现。

我没有看到任何理智的方式来论证 C++ 抽象机在执行此操作时的任何时候都会导致 UB,但是看到r1 == r2 == 42会暗示发生了不同步的读 + 写,但这是数据竞争 UB。如果可以的话,一个实现是否可以因为推测执行(或其他一些原因)而发明 UB?要使 C++ 标准完全可用,答案必须是“否”。

对于宽松的原子,凭空发明42并不意味着 UB 已经发生;也许这就是标准说规则允许的原因?据我所知,标准的原子章节之外的任何内容都不允许。


可能导致此问题的假设 asm / 硬件机制

(没有人想要这个,希望每个人都同意构建这样的硬件是一个坏主意。跨逻辑核心的耦合推测似乎不太可能值得在检测到错误预测或其他情况时回滚所有核心的缺点错误推测。)

为了42成为可能,线程 1 必须看到线程 2 的推测存储,并且线程 2 的负载必须看到来自线程 1 的存储。(确认分支推测是好的,让这条执行路径成为实际采取的真实路径。)

即跨线程推测:如果它们在相同的核心上运行,只有轻量级上下文切换,例如协程或绿色线程,则在当前硬件上是可能的。

但是在当前的硬件上,在这种情况下,线程之间的内存重新排序是不可能的。在同一个内核上乱序执行代码给人一种一切都按程序顺序发生的错觉。要在线程之间重新排序内存,它们需要在不同的内核上运行。

因此,我们需要一种将两个逻辑内核之间的推测结合在一起的设计。 没有人这样做,因为这意味着如果检测到错误预测,需要回滚更多状态。但这在假设上是可能的。例如,一个 OoO SMT 核心允许在其逻辑核心之间进行存储转发,甚至在它们从无序核心中退出之前(即变为非推测性)。

PowerPC 允许在退休商店的逻辑核心之间进行商店转发,这意味着线程可以不同意商店的全局顺序。但是等到他们“毕业”(即退休)并成为非投机者意味着它不会将在不同逻辑核心上的投机联系在一起。因此,当一个从分支未命中中恢复时,其他人可以让后端保持忙碌。如果他们都不得不回滚对任何逻辑核心的错误预测,那将失去 SMT 的很大一部分优势。

我想了一会儿,我发现了一个排序,导致这个排序在真正弱排序的 CPU 的单核上(在线程之间进行用户空间上下文切换),但最后一步存储无法转发到第一步加载,因为这是程序顺序,而 OoO exec 保留了它。

  • T2:r2 = y;停顿(例如缓存未命中)

  • T2:分支预测预测r2 == 42为真。(x = 42应该运行。

  • T2:x = 42跑。(仍然是推测性的;r2 = y hasn't obtained a value yet so ther2 == 42` compare/branch 仍在等待确认该推测)。

  • 上下文切换到线程 1不会将 CPU 回滚到退休状态,也不会等待推测被确认为良好或被检测为错误推测。

    这部分不会发生在真正的 C++ 实现上,除非它们使用 M:N 线程模型,而不是更常见的 1:1 C++ 线程到 OS 线程。真正的 CPU 不会重命名特权级别:它们不会接受中断或以其他方式通过运行中的推测指令进入内核,这些指令可能需要从不同的架构状态回滚和重做进入内核模式。

  • T1:r1 = x;从投机x = 42商店获取其价值

  • T1:r1 == 42发现为真。(分支推测也发生在这里,实际上并不是等待存储转发完成。但是沿着这条执行路径,在x = 42确实发生的地方,这个分支条件将执行并确认预测)。

  • T1:y = 42跑。

  • 这一切都在同一个 CPU 内核上,所以这个y=42存储是r2=y在程序顺序加载之后;它不能给这个负载一个42r2==42猜测得到证实。 所以这种可能的排序毕竟并没有在行动中证明这一点。 这就是为什么线程必须在具有线程间推测的单独内核上运行才能实现这样的效果。

请注意,x = 42它不依赖于数据,r2因此不需要进行值预测来实现这一点。并且无论如何y=r1都在内部if(r1 == 42),因此编译器可以根据需要进行优化y=42,打破其他线程中的数据依赖性并使事情对称。

请注意,关于单个内核上的绿色线程或其他上下文切换的参数实际上并不相关:我们需要单独的内核进行内存重新排序。


我之前评论说我认为这可能涉及价值预测。ISO C++ 标准的内存模型肯定足够弱以允许使用值预测可以创建的那种疯狂的“重新排序”,但是这种重新排序不是必需的。 y=r1可以优化为y=42,并且原始代码x=42无论如何都包含在内,因此该存储对r2=y负载没有数据依赖性。42在没有价值预测的情况下,很容易进行投机性存储。(问题是让另一个线程看到它们!)

由于分支预测而不是值预测而进行的推测在这里具有相同的效果。在这两种情况下,负载都需要最终42确认推测是正确的。

价值预测甚至无助于使这种重新排序更合理。我们仍然需要两个推测存储的线程间推测内存重新排序来相互确认并引导自己存在。


ISO C++ 选择允许这用于宽松原子,但 AFAICT 不允许这种非原子变量。我不确定我是否确切地看到标准中确实允许 ISO C++ 中的宽松原子情况超出说明它没有明确禁止的说明。如果有任何其他代码可以做任何事情,x或者y也许,但我认为我的论点适用于宽松的原子情况。在 C++ 抽象机中没有通过源的路径可以产生它。

正如我所说,AFAIK 在任何真实硬件(在 asm 中)或在任何真实 C++ 实现中的 C++ 中都是不可能的。它更像是对非常弱的排序规则(例如 C++ 的宽松原子)的疯狂后果进行的有趣的思想实验。(这些排序规则并没有禁止它,但我认为 as-if 规则和标准的其余部分确实如此,除非有一些规定允许宽松的原子读取从未由任何线程实际写入的值。)

如果有这样的规则,它只会适用于松弛原子,而不适用于非原子变量。数据竞争 UB 几乎是关于非原子变量和内存排序的所有标准,但我们没有。


Nat*_*ica 5

所谓的“内存模型”的哪一部分保护非原子对象免受由看到交互的读取引起的这些交互?

没有任何。事实上,你得到了相反的结果,标准明确地将其称为未定义的行为。在[intro.races]\21我们有

如果程序包含两个潜在的并发冲突操作,则程序的执行包含数据竞争,其中至少一个不是原子的,并且都不在另一个之前发生,除了下面描述的信号处理程序的特殊情况。任何此类数据竞争都会导致未定义的行为。

这涵盖了你的第二个例子。


规则是,如果您在多个线程中共享数据,并且这些线程中至少有一个写入该共享数据,那么您需要同步。否则,您将面临数据竞争和未定义的行为。请注意,这volatile不是有效的同步机制。您需要原子/互斥体/条件变量来保护共享访问。

  • @curiousguy - 我认为你把自己绑在了结中,试图将你的头脑围绕在一些复杂的想法上。不要试图对事物进行一些自上而下的理解,而是尝试一些非常具体的示例(最好是可以实际运行的代码)。也许将它们发布在 SO 上并询问预期的行为是什么。自下而上建立您的理解,直到它点击。 (6认同)
  • @curiousguy 不。`shared_ptr` 会在幕后为您处理所有这些。它使用原子引用计数器来跟踪存在的 man 实例。析构函数检查引用计数,如果它大于 1,它只会自动将其减一。如果引用计数器为 1,则析构函数知道它是唯一拥有该指针的对象,因此它会删除它持有的指针。 (4认同)
  • @curiousguy 只要您使用顺序一致模式,就可以保证您的代码只有一个总顺序。这是由 C++ 提供的,因此它完全有能力编写 100% 可移植且有保证的多线程代码。 (2认同)
  • @curiousguy - 使用 `memory_order_seq_cst` 而不是 `memory_order_relaxed`。 (2认同)
  • @curiousguy 通常只使用默认值。例如,如果您有一个 `std::atomic&lt;int&gt;` 并且您在多个线程中执行 `++name_of_atomic_int`,则可以保证结果是正确的,因为默认情况下,运算符是顺序一致的。 (2认同)
  • @curiousguy 不。如果您有一个线程销毁对象,那很好。如果您有两个线程试图销毁该对象,那就不好了。但这不是多线程问题。这就像你有一个单一的线程试图破坏对象两次一样糟糕。 (2认同)
  • @curiousguy您不能让多个线程共享一个对象,而该对象会被两个线程破坏。如果它是一个全局变量,它会在程序结束时被销毁。如果它是通过引用传递的,那么它会被调用站点而不是线程销毁。如果您将 `shared_ptr` 传递给线程,那么一旦它们结束,它们就会销毁自己的 `shared_ptr` 副本,并且 `shared_ptr` 使用原子引用计数来确定何时应该销毁指向的对象。 (2认同)
  • @curiousguy 这就是 `std::shared_ptr` 的用途。它允许两个或多个事物共享所有权,并且只有在所有 `shared_ptr` 被销毁后才会销毁指向的对象。 (2认同)
  • @curiousguy 是的。C++ 标准要求`shared_ptr` 做正确的事情。您无需担心。 (2认同)

归档时间:

查看次数:

487 次

最近记录:

6 年,5 月 前