66

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

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

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

4

2 回答 2

135

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

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

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

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

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

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

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

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

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

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

获取和释放被认为是“半壁垒”或“半栅栏”,因为它们只会阻止重新排序以一种方式进行。

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

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

IE

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 值。所以我们使用write_releaseon foo->ready ,如上所述,它有效地“刷新”写队列(确保 x,y,z 被提交)然后将 ready=true 请求添加到队列中。然后添加 bar=13 请求。请注意,由于我们只是使用了释放屏障(不是完整的),所以 bar=13 可能在准备好之前就被写入了。但我们不在乎!即我们假设 bar 没有改变共享数据。

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

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

所以,

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

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

  • 在您的问题中,当您认为这实际上与相对顺序有关时,您走在正确的轨道上-您只是将顺序倒过来(即“读取的值至少与障碍之前的读取值一样最新? “ - 不,在屏障之前读取并不重要,它在屏障之后读取,保证在之后,反之亦然。

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

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

于 2009-11-24T06:23:46.683 回答
13

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

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

回答您的问题 - 不,内存屏障不保证“最新”值,内存访问操作的顺序。这在例如无锁编程中至关重要。

是关于 CPU 内存屏障的入门书之一。

于 2009-11-24T02:59:24.833 回答