我如何理解读内存屏障和易失性

Posted

技术标签:

【中文标题】我如何理解读内存屏障和易失性【英文标题】:How do I Understand Read Memory Barriers and Volatile 【发布时间】:2010-12-19 17:40:03 【问题描述】:

某些语言提供volatile 修饰符,被描述为在读取支持变量的内存之前执行“读取内存屏障”。

读内存屏障通常被描述为一种确保 CPU 在屏障之后执行读取请求之前已执行屏障之前请求的读取的方法。然而,使用这个定义,似乎仍然可以读取陈旧的值。换句话说,按一定顺序执行读取似乎并不意味着必须咨询主存储器或其他 CPU,以确保后续读取的值实际上反映了系统中在读屏障时的最新值或在读取屏障之后随后写入的值。阅读障碍。

那么,volatile 真的保证读取的是最新的值,还是只是(喘气!)读取的值至少与屏障之前的读取一样是最新的?还是其他的解释?这个答案有什么实际意义?

【问题讨论】:

【参考方案1】:

有读屏障和写屏障;获取障碍和释放障碍。还有更多(io 与内存等)。

不存在控制“最新”值或“新鲜度”值的障碍。它们用于控制内存访问的相对顺序。

写屏障控制写的顺序。因为写入内存很慢(与 CPU 的速度相比),通常有一个写入请求队列,在写入“真正发生”之前发布。尽管它们是按顺序排队的,但在队列内部,写入可能会重新排序。 (所以也许“队列”不是最好的名字......)除非你使用写屏障来防止重新排序。

读取屏障控制读取的顺序。由于推测性执行(CPU 提前查看并提前从内存中加载)以及写入缓冲区的存在(如果存在,CPU 将从写入缓冲区而不是内存中读取一个值 - 即 CPU 认为它只是写了 X = 5,那为什么要读回来,只是看到它还在写缓冲区中等待变成 5)读取可能发生乱序。

无论编译器尝试对生成代码的顺序做什么,这都是正确的。即 C++ 中的 'volatile' 在这里没有帮助,因为它只告诉编译器输出代码以从“内存”重新读取值,它不会告诉 CPU 如何/从哪里读取它(即“内存”在 CPU 级别有很多东西)。

所以读/写屏障设置块来防止读/写队列中的重新排序(读取通常不是队列,但重新排序的效果是一样的)。

什么样的方块? - 获取和/或释放块。

Acquire - 例如 read-acquire(x) 会将 x 的读取添加到读取队列中并刷新队列(不是真正刷新队列,而是添加一个标记说不要重新排序在此读取之前的任何内容,就好像队列已刷新)。所以稍后(按代码顺序)读取可以重新排序,但不能在读取 x 之前。

Release - 例如 write-release(x, 5) 将首先刷新(或标记)队列,然后将写入请求添加到写入队列。因此,较早的写入不会在 x = 5 之后重新排序,但请注意,稍后的写入可以在 x = 5 之前重新排序。

请注意,我将读取与获取和写入与释放配对,因为这是典型的,但可能有不同的组合。

Acquire 和 Release 被视为“半壁垒”或“半栅栏”,因为它们只会阻止重新排序以一种方式进行。

完整的屏障(或完整的栅栏)同时应用获取和释放——即不重新排序。

通常对于无锁编程或 C# 或 java 'volatile',您想要/需要的是 读取-获取和写入-释放。

void threadA()

   foo->x = 10;
   foo->y = 11;
   foo->z = 12;
   write_release(foo->ready, true);
   bar = 13;

void threadB()

   w = some_global;
   ready = read_acquire(foo->ready);
   if (ready)
   
      q = w * foo->x * foo->y * foo->z;
   
   else
       calculate_pi();

因此,首先,这是编写线程的一种糟糕方式。锁会更安全。但只是为了说明障碍......

threadA() 写完 foo 后,它需要写 foo->ready LAST,真的是最后一个,否则其他线程可能会提前看到 foo->ready 并得到错误的 x/y/z 值。所以我们在 foo->ready 上使用write_release,如上所述,它有效地“刷新”写入队列(确保提交 x,y,z)然后将 ready=true 请求添加到队列中。然后添加 bar=13 请求。请注意,由于我们只是使用了释放屏障(不是完整的),所以 bar=13 可能在准备好之前就被写入了。但我们不在乎!即我们假设 bar 没有改变共享数据。

现在 threadB() 需要知道,当我们说“准备好”时,我们真正的意思是准备好了。所以我们做一个read_acquire(foo->ready)。此读取被添加到读取队列中,然后队列被刷新。请注意,w = some_global 也可能仍在队列中。所以 foo->ready 可以在 before some_global 之前阅读。但同样,我们不在乎,因为它不是我们如此小心的重要数据的一部分。 我们关心的是 foo->x/y/z。所以它们在acquire flush/marker之后被添加到读队列中,保证只有在读取foo->ready之后才被读取。

另请注意,这通常与用于锁定和解锁互斥锁/CriticalSection/等的屏障完全相同。 (即在 lock() 上获取,在 unlock() 上释放)。

所以,

我很确定这(即获取/释放)正是 MS 文档所说的在 C# 中读/写“易失性”变量时发生的情况(对于 MS C++ 也是可选的,但这是非标准的) .请参阅http://msdn.microsoft.com/en-us/library/aa645755(VS.71).aspx,包括“易失性读取具有“获取语义”;也就是说,它保证发生在任何对内存的引用之前发生...”

认为 java 也是一样,虽然我不是很熟悉。我怀疑它完全一样,因为您通常不需要比读取-获取/写入-释放更多的保证。

1234563屏障之前的读取?” - 不,屏障之前的读取并不重要,它在屏障之后的读取保证在屏障之后,反之亦然用于写入)。

请注意,如上所述,重新排序发生在读取和写入时,因此仅在一个线程上使用屏障而不在另一个线程上将不起作用。即没有读获取,写发布是不够的。即,即使你以正确的顺序编写它,如果你不使用读屏障与写屏障一起使用,它也可能以错误的顺序读取。

最后,请注意,无锁编程和 CPU 内存架构实际上可能比这复杂得多,但坚持使用获取/释放会让您走得更远。

【讨论】:

write_release 和 read_acquire 引用同一个就绪变量是否重要?或者你可以为两者使用单独的虚拟变量吗?传入的值似乎没有任何用途。 对于尝试同步的线程,必须使用相同的变量。就像您需要使用相同的互斥锁或锁定普通线程一样。在我的线程 A/B 示例中,我们要确保 foo->x,y,z 在 foo->ready 之前写入(否则有人可能会在 foo 实际准备好之前看到'ready == true')。在读取方面,您不想在 x,y,z 准备好之前读取它,因此您需要在 foo->ready 上进行 read_acquire 以确保 CPU 不会在 x,y,z 读取之前重新排序 'if ( foo-> 准备好了)'。如果您的障碍位于不同的虚拟变量上,那么您将没有同步点。【参考方案2】:

volatile 在大多数编程语言中并不意味着真正的 CPU 读取内存屏障,而是命令编译器不要通过缓存在寄存器中来优化读取。这意味着读取进程/线程将“最终”获得该值。一种常见的技术是声明一个布尔值volatile 标志以在信号处理程序中设置并在主程序循环中检查。 相比之下,CPU 内存屏障通过 CPU 指令直接提供,或者通过某些汇编程序助记符隐含(例如 x86 中的 lock 前缀),例如在与内存映射 IO 寄存器的读取和写入顺序的硬件设备通信时使用在多处理环境中很重要或同步内存访问。 要回答您的问题 - 不,内存屏障不保证“最新”值,但保证 order 内存访问操作。这在 lock-free 编程中至关重要。Here 是 CPU 内存屏障的入门之一。

【讨论】:

我知道在 C 和 C++ 的许多实现中都是这种情况。我的问题与 Java 和 .NET 等虚拟机平台最相关。 对于 Java 和 C# 等基于 VM 的语言,您需要了解它们的“内存模型”是什么。 请注意volatile 是不够的,您必须在信号处理程序中使用volatile sig_atomic_t 以符合标准。

以上是关于我如何理解读内存屏障和易失性的主要内容,如果未能解决你的问题,请参考以下文章

内存屏障

内存屏障是如何工作的?

线程之间的内存栅栏/屏障如何与其他线程中的栅栏/屏障交互?

Linux 内核 内存管理优化内存屏障 ④ ( 处理器内存屏障 | 八种处理器内存屏障 | 通用内存屏障 | 写内存屏障 | 读内存屏障 | 数据依赖屏障 | 强制性内存屏障 |SMP内存屏障 )

Linux 内核 内存管理优化内存屏障 ④ ( 处理器内存屏障 | 八种处理器内存屏障 | 通用内存屏障 | 写内存屏障 | 读内存屏障 | 数据依赖屏障 | 强制性内存屏障 |SMP内存屏障 )

如何在没有内存屏障的情况下实现 InterlockedIncrement