内存模型,负载获取语义实际上是如何工作的?

memory model, how load acquire semantic actually works?

来自非常好的Paper and article 关于内存重新排序。

Q1:我理解cache-coherence, store buffer and invalidation queue内存重新排序的根本原因

Store release很好理解,必须等所有load和store完成后才设置flag为true。

关于load acquire,原子加载的典型用法是等待标志。假设我们有 2 个线程:

int x = 0;
std::atomic<bool> ready_flag = false;
// thread-1
if(ready_flag.load(std::memory_order_relaxed))
{
    // (1)
    // load x here
}
// (2)
// load x here
// thread-2
x = 100;
ready_flag.store(true, std::memory_order_release);

编辑:在thread-1中,应该是一个while循环,但是我从上面的文章中复制了逻辑。因此,假设内存重新排序是及时发生的。

Q2:因为(1)和(2)取决于if条件,所以CPU要等ready_flag,是不是说write-release就够了?在此上下文中如何发生内存重新排序?

Q3:显然我们有load-acquire,所以我猜mem-reorder是可以的,那我们应该把栅栏放在哪里,(1)还是(2)?

C++ 标准没有指定由任何特定构造生成的代码;只有线程通信工具的正确组合才能保证结果。

您无法从 C++ 中的 CPU 获得保证,因为 C++ 不是一种(宏)程序集,甚至不是 "high level assembly",至少在并非所有对象都具有可变类型。

原子对象是线程间交换数据的通信工具。对于内存操作的正确可见性,正确使用是存储操作(至少)释放,然后是获取加载,与介于两者之间的 RMW 相同,或者存储(resp。加载)被 RMW 替换为(至少)一个版本(resp.acquire),在任何具有宽松操作和单独围栏的变体上。

在所有情况下:

  • 线程"publishing" "done"标志必须使用至少释放的内存顺序(即:释放、释放+获取或顺序一致性),
  • 而"subscribing"线程,作用于flag的线程至少要使用acquire(即:acquire,release+acquire或者顺序一致性)。

在实际使用单独编译的代码时,其他模式可能会起作用,具体取决于 CPU。

访问原子变量不是互斥操作;它只是原子地访问存储的值,任何 CPU 操作都没有机会中断访问,这样就不会发生关于访问该值的数据竞争(它也可以针对其他访问发出障碍,这是内存命令提供了什么)。但仅此而已;它不会等待任何特定值出现在原子变量中。

因此,您的 if 语句将读取当时恰好存在的任何值。如果你想保护对 x 的访问,直到另一个语句写入它并发出原子信号,你必须:

  1. 在原子标志返回值 true 之前,不允许 任何代码 x 读取。仅仅测试一次值是不行的;您必须遍历重复访问,直到它是 true。任何其他从 x 读取的尝试都会导致数据竞争,因此是未定义的行为。

  2. 无论何时访问该标志,您都必须以一种方式执行此操作,告诉系统线程设置该标志写入的值应该对看到设置值的后续操作可见。这需要一个正确的内存顺序,必须至少为 memory_order_acquire.

    从技术上讲,读取标志本身并不一定要进行获取。从标志中读取正确的值后,您可以执行获取操作。但是在读取 x.

  3. 之前,您需要进行等效的获取操作
  4. 写入语句必须使用至少与memory_order_release.

  5. 一样强大的释放内存顺序设置标志

Because (1) and (2) depends on if condition, CPU have to wait for ready_flag

该推理中有 2 个严重缺陷:

  1. 分支预测+推测执行是真实的 CPUs。控制依赖的行为不同于数据依赖。 推测执行打破了控制依赖。

    在大多数(但不是全部)真实的 CPU 中,数据依赖性 像 C++ memory_order_consume 一样工作。一个典型的用例是加载一个指针然后取消引用它。这在 C++ 非常弱的内存模型中仍然不安全,但会编译成适用于除 DEC Alpha 之外的大多数 ISA 的 asm。 Alpha(实际上在某些硬件上)甚至可以在取消引用刚刚加载的指针时设法违反因果关系并加载过时的值,即使存储的顺序是正确的。

  2. 编译器可以破坏控制甚至数据依赖性。 C++ 源代码逻辑并不总是直接转换为 asm。 在这种情况下,编译器可以发出像这样工作的 asm:

     tmp = load(x);         // compile time reordering before the relaxed load
     if (load(ready_flag)
        actually use tmp;
    

    在 C++ 中读取 x 是数据争用 UB,但它可能仍在编写中,但对于大多数特定的 ISA,这没有问题。您只需要避免实际 使用 任何可能虚假的加载结果。

    对于大多数 ISA 来说,这可能不是一个有用的优化,但没有人会排除它。通过提前执行加载来隐藏有序管道上的加载延迟有时实际上可能很有用(如果它不是由另一个线程编写的,并且编译器可能会猜测这没有发生,因为没有获取负载)。

到目前为止,最好的选择是使用 ready_flag.load(mo_acquire).


另一个问题是您注释掉了 x after if() 的代码,这将 运行 即使load 没有看到数据就绪。正如@Nicol 在回答中解释的那样,这意味着数据竞争 UB 是可能的,因为您可能正在阅读 x 而生产者正在编写它。

也许您想编写一个像 while(!ready_flag){ _mm_pause(); } 这样的自旋等待循环?通常要小心浪费大量 CPU 时间旋转;如果可能需要很长时间,请使用库支持的东西,例如条件变量,它可以让您有效地回退到 OS 支持的 sleep/wakeup(例如 Linux futex)短暂旋转后。


如果您确实想要一个与负载分开的手动屏障,那就是

 if (ready_flag.load(mo_relaxed))
     atomic_thread_fence(mo_acquire);
     int tmp = x;   // now this is safe
 }
 // atomic_thread_fence(mo_acquire);  // still wouldn't make it safe to read x
 // because this code runs even after ready_flag == false

使用 if(ready_flag.load(mo_acquire)) 会导致在 ready_flag 加载分支之前出现无条件围栏,当为任何 ISA 编译时,获取加载无法通过单个指令获得。 (在 x86 上所有加载都是获取,在 AArch64 上 ldar 执行获取加载。ARM 需要加载 + dsb ish