如何理解读内存屏障和volatile?

70

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

读内存屏障通常被描述为确保CPU在执行屏障之前已经执行了所请求的读取操作,然后才执行屏障之后请求的读取操作。然而,使用这个定义,似乎仍然可能读取到旧值。换句话说,在特定顺序下进行读取并不意味着必须查询主内存或其他CPU,以确保后续读取的值实际上反映了读取屏障时系统中的最新值或稍后写入的值。

那么,volatile是否真正保证读取到的值是最新的,还是只是(哇!)读取屏障之前读取的值至少与读取屏障之前的值一样更新?还是其他解释?这个答案的实际影响是什么?

2个回答

146
有读屏障和写屏障;获取屏障和释放屏障。还有更多(例如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之前重新排序。
请注意,我将读与获取配对,将写与释放配对,因为这是典型的,但可能有不同的组合。
获取和释放被认为是“半屏障”或“半围栏”,因为它们只阻止重新排序沿着一条路线进行。
完整的屏障(或完整的围栏)应用获取和释放-即没有重新排序。
通常情况下,对于无锁编程或C#或Java中的“volatile”,您��要的是read-acquire和write-release。
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,否则其他线程可能会提前看到 foo->ready 并获取 x/y/z 的错误值。因此,我们在 foo->ready 上使用 write_release,如上所述,它有效地“刷新”写队列(确保 x、y、z 已经提交),然后将 ready=true 请求添加到队列中。然后再添加 bar=13 请求。请注意,由于我们仅仅使用了 release 屏障(而非 full),bar=13 可能会在 ready 之前被写入。但这没关系!也就是说,我们假设 bar 不会改变共享数据。
现在 threadB() 需要知道当我们说“ready”时,我们确实是指准备好了。因此,我们要使用 read_acquire(foo->ready)。这个读取操作被添加到读取队列中,然后队列被刷新。请注意,w = some_global 可能仍然在队列中。因此,foo->ready 可能会在 some_global 之前被读取。但同样的,我们不在乎,因为它不是我们正在小心翼翼处理的重要数据的一部分。 我们关心的是 foo->x/y/z。因此,在获取刷新/标记之后,它们被添加到读取队列中,确保它们在读取 foo->ready 之后被读取。
还要注意,这通常是锁定和解锁互斥体/CriticalSection/etc.使用的确切屏障(即在 lock() 上采用 acquire,在 unlock() 上采用 release)。
因此,
  • 我相当确定这(即获取/释放)正是 MS 文档中所说的 C# “易失性”变量的读/写操作所发生的事情(对于 MS C++ 可选,但这是非标准的)。请参见 http://msdn.microsoft.com/en-us/library/aa645755(VS.71).aspx,包括“volatile 读取具有“获取语义”,也就是说,它被保证在任何引用它之后的内存引用之前发生...”

  • 我认为 Java 也是一样的,尽管我不是很熟悉。我怀疑它完全一样,因为你通常不需要比读取获取更多的保证。

  • 在你的问题中,你考虑到了相对顺序,但你把顺序弄反了(即“读取的值至少与屏障之前的读取一样新吗?”- 不是,屏障之前的读取无关紧要,重要的是屏障之后的读取,写入同理)。

  • 请注意,正如上面提到的,重排序会发生在读和写操作中,因此仅在一个线程上使用屏障而不在另一个线程上使用屏障将不起作用。即 write-release 没有 read-acquire 是不够的。也就是说,即使你按正确顺序编写它,如果没有与写入屏障配对的读取屏障,它可能以错误的顺序进行读取。

  • 最后,请注意,无锁编程和 CPU 内存架构可能比这更复杂,但坚持


1
写入释放(write_release)和读取获取(read_acquire)是否引用了相同的ready变量有关系吗?或者你可以为两者使用单独的虚拟变量吗?传递的值似乎没有任何作用。 - Joseph Garvin
2
在尝试同步的线程中使用相同的变量是必要的,就像在正常线程中需要使用相同的互斥锁或锁定一样。在我的threadA/B示例中,我们希望确保在foo->ready之前写入foo->x,y,z(否则有人可能会在foo实际准备好之前看到'ready == true')。在读取方面,在准备好之前不想读取x,y,z,因此需要在foo->ready上进行read_acquire以确保CPU不会在“if(foo->ready)”之前重新排序x,y,z读取。如果您的障碍在不同的虚拟变量上,则不会有同步点。 - tony

14
在大多数编程语言中,volatile并不意味着真正的CPU读取内存屏障,而是向编译器发出指令,不要通过缓存在寄存器中优化读取。这意味着读取过程/线程会“最终”获取该值。常用技术是声明一个布尔型volatile标志,在信号处理程序中设置,并在主程序循环中进行检查。

相比之下,CPU内存屏障直接提供,可以通过CPU指令或使用某些汇编助记符(例如x86中的lock前缀)隐式提供,并且用于与硬件设备通信时,其中对内存映射IO寄存器的读写顺序很重要,或在多处理环境中同步内存访问。

回答您的问题-内存屏障不能保证“最新”的值,但保证了内存访问操作的顺序。这在无锁编程中非常重要。

这里是有关CPU内存屏障的基础知识介绍。

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接