3

来自非常好的论文和关于内存重新排序的文章。

Q1:我知道缓存一致性、存储缓冲区和失效队列是内存重新排序的根本原因?

存储释放是可以理解的,必须等待所有加载和存储完成后才能将标志设置为 true。

关于加载获取,原子加载的典型用法是等待标志。假设我们有 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);

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

Q2 : 因为 (1) 和 (2) 取决于 if 条件,CPU 必须等待 ready_flag,是否意味着 write-release 就足够了?在这种情况下如何进行内存重新排序?

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

4

3 回答 3

5

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

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

  1. 在原子标志返回值之前,不允许读取任何代码。简单地测试一次值不会这样做;您必须循环重复访问,直到它是. 任何其他尝试读取都会导致数据竞争,因此是未定义的行为。xtruetruex

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

    从技术上讲,从标志本身读取不必进行获取。从标志中读取正确的值后,您可以执行获取操作。但是您需要在读取之前进行一个获取等效操作x

  3. 写入语句必须使用释放内存顺序设置标志,该顺序必须至少与memory_order_release.

于 2019-11-11T18:09:48.530 回答
3

因为 (1) 和 (2) 取决于 if 条件,所以 CPU 必须等待 ready_flag

这种推理有两个显眼的缺陷

  1. 分支预测 + 推测执行在真实 CPU 中是真实存在的。控制依赖与数据依赖的行为不同。 推测执行打破了控制依赖关系。

    在大多数(但不是全部)真正的 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++ 中的数据竞争 UB,x虽然它可能仍在编写中,但对于大多数特定的 ISA 来说,这没有问题。您只需要避免实际使用任何可能是虚假的加载结果。

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

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


一个单独的问题是您已经注释掉了x 在 之后读取的代码,if()即使加载没有看到数据准备好,它也会运行。正如@Nicol 在回答中解释的那样,这意味着数据竞争 UB 是可能的,因为您可能x在生产者编写它时正在阅读。

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


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

 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))在为任何单条指令无法获得获取加载的 ISA 进行编译时,使用将导致在对 ready_flag 加载进行分支之前出现无条件围栏。(在 x86 上获取所有负载,在 AArch64ldar上进行获取负载。ARM 需要 load + dsb ish

于 2019-11-11T18:15:11.730 回答
0

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

您无法从 C++ 中的 CPU 获得保证,因为 C++ 不是一种(宏)程序集,甚至不是“高级程序集”,至少在并非所有对象都具有 volatile 类型时不是。

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

在所有情况下:

  • 线程“发布”“完成”标志必须使用内存排序至少释放(即:释放、释放+获取或顺序一致性),
  • 而“订阅”线程,作用于标志的线程必须至少使用acquire(即:acquire、release+acquire或顺序一致性)。

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

于 2019-11-11T18:05:10.290 回答