【问题标题】:C++ Atomic variable visibiltyC++ 原子变量可见性
【发布时间】:2021-09-20 06:40:57
【问题描述】:
#include <atomic>

std::atomic<int> foo;

void ThreadA()
{
    foo.store(1, std::memory_order_relaxed);

    while (true) {};
}

void ThreadB()
{
    while (foo.load(std::memory_order_relaxed) == 0)
    {
    }
}

对于使用relaxed 操作的原子变量,是否理论上 thread B 将永远无法读取foo 变量的最新值(假设没有干扰从其他线程刷新缓存)?

或者我们有什么保证,不管是硬件、操作系统还是C++标准,thread B可以在有限时间内读取到最新的foo变量值?

【问题讨论】:

  • 嗯...我个人认为这是可能的。因为如果在加载指令和创建循环的跳转之后重新排序存储,则可能会出现问题。
  • @Afshin 如果foo变量停留在CPU寄存器中,并且没有缓存刷新或同步操作,这种情况下thread B无法观察到foo变量的修改?
  • 松弛排序: "...标记为 memory_order_relaxed 的原子操作是不是同步操作;它们不会在并发内存之间强加顺序访问。它们只保证原子性和修改顺序的一致性...." en.cppreference.com/w/cpp/atomic/memory_order#Relaxed_ordering
  • "实现应该确保原子操作或同步操作分配的最后一个值(按修改顺序)将在有限的时间内对所有其他线程可见。"

标签: c++ multithreading caching atomic


【解决方案1】:

自 C++11 以来,这已有效得到保证,所有引用均来自 C++20 标准。首先是[intro.races]/4 状态

对特定原子对象 M 的所有修改都以某个特定的总顺序发生,称为 M 的修改顺序。

然后在 1519 的段落中

  1. 如果修改原子对象 M 的操作 A 发生在修改 M 的操作 B 之前,则在 M 的修改顺序中 A 应早于 B。 [注 15:此要求称为写-写连贯性。 ——尾注]

  2. 如果原子对象 M 的值计算 A 发生在 M 的值计算 B 之前,并且 A 从 M 上的副作用 X 获取其值,则 B 计算的值应为 X 存储的值或由 M 上的副作用 Y 存储的值,其中 Y 按照 M 的修改顺序跟随 X。 [注 16:这个要求被称为读读连贯性。 ——尾注]

  3. 如果原子对象 M 的值计算 A 发生在修改 M 的操作 B 之前,则 A 应从 M 上的副作用 X 中获取其值,其中 X 在 M 的修改顺序中位于 B 之前。 [注 17:此要求称为读写一致性。 ——尾注]

  4. 如果原子对象 M 上的副作用 X 发生在 M 的值计算 B 之前,则评估 B 应从 X 或从按照 M 的修改顺序跟随 X 的副作用 Y 获取其值。 [注 18:此要求称为读写一致性。 ——尾注]

  5. [注 19:前面的四个一致性要求实际上不允许编译器将原子操作重新排序到单个对象,即使这两个操作都是宽松的负载。这有效地使大多数硬件提供的缓存一致性保证可用于 C++ 原子操作。 ——尾注]

第 19 段中的注释总结得最好:前面的四个一致性要求实际上不允许编译器将原子操作重新排序到单个对象,即使这两个操作都是宽松的负载。

【讨论】:

  • 2 个问题:1- 编译器如何决定哪条指令在另一个线程中的另一条指令之前发生?我的意思是如果编译器假设存储是在加载之后,那么他不会根据你的帖子重新排序,但它仍然是不正确的顺序并且永远不会完成。 2-这篇文章没有说明可能发生的 CPU 管道中的重新排序。这是关于在编译器中重新排序。
  • @Afshin C++ 标准定义了一个抽象机器。编译器的工作就是“制造”那台机器来运行你的代码。 ThreadAfoo.store(1, std::memory_order_relaxed);,并且标准保证在某个时候该操作将会发生,并且在它发生之后,所有其他线程都可以看到副作用。
  • 我见过类似OP的问题,人们通常使用released内存顺序进行存储,acquire进行加载。这是一个例子:en.cppreference.com/w/cpp/atomic/…。那么这些内存顺序是为了限制什么时候副作用将可用于其他线程?
【解决方案2】:

假设 ThreadA()ThreadB() 首先实际上是同时执行的,那么 C++ 标准在理论上确实会保证 ThreadB() 最终会读取到原子的更改并正确终止。

实际上,事情可能会变得更加混乱。我可以想到ThreadB() 永远不会看到原子更改的三种情况:

  1. 线程 A 和 B 之外的东西会提前终止线程 B,或者无限期地停止它。那么显然它无法到达它看到变化的地步。

  2. 线程 A 和 B 之外的东西会提前终止线程 A,或者无限期地停止它。那么显然它无法将它的消息发出来,线程 B 永远也看不到它。

  3. 线程 A 和 B 之外的东西会中断运行各自线程的硬件实体之间的通信。

3 幸运的是,对于软件片段来说,这是一个相当假设的场景,但在例如在超级计算机环境中,我可以想象,如果无法在硬件级别进行稳健处理,那么可能值得针对此类场景强化软件。

1 和 2 可能实际上发生在单核机器上。例如,用户可能会使用调试器暂停程序,暂停一个线程,取消暂停其他线程,然后离开键盘去买一包香烟,再也不回来了。或者,也许某些杀毒软件出了问题,认为线程 A 是一个安全威胁,需要终止它,但由于某些不明原因并没有终止线程 B。

这听起来可能微不足道,但在场景 2 的一个更成问题的变体中,可能您的程序中的某些东西会导致操作系统资源不足,直到线程 B 完成。然后,资源不足可能会导致操作系统停止线程 A,因为它认为它有更重要的任务可以将其稀疏资源投入其中,这反过来又会阻止操作系统恢复。

(可以想象,场景 1 可能会发生类似的事情,尽管它有点无聊。)

不过,这些场景都对内存顺序不敏感。

【讨论】:

    【解决方案3】:

    在正常情况下,操作系统会确保每个线程都获得其公平的 CPU 时间份额。这意味着是的,除非您可能有其他担忧的特殊情况,否则线程 B 最终将有机会读取对原子变量的更改。这与内存顺序无关。

    C++ 标准还保证线程 B 将一遍又一遍地读取原子变量(而不是只读取一次并缓存结果),直到线程 A 提交对原子值的更改并且传播到正在执行线程 B 的任何实体。同样,无论内存顺序如何。

    【讨论】:

      猜你喜欢
      • 2021-07-15
      • 2014-11-30
      • 1970-01-01
      • 2017-08-09
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-07-30
      • 2015-07-06
      相关资源
      最近更新 更多