19

我一直在阅读它Full fences可以防止任何类型的指令重新排序或围绕该栅栏进行缓存(通过 memoryBarrier)

然后我读到了volatile 哪些会产生“半栅栏”:

volatile 关键字指示编译器在每次读取该字段时生成一个获取栅栏,并在每次写入该字段时生成一个释放栅栏。

acquire-fence

获取栅栏防止其他读/写在栅栏之前移动;

release-fence

释放栅栏可防止其他读/写操作在栅栏之后移动。

有人可以用简单的英语解释一下这两个句子吗?

(围栏在哪里?)

编辑

在这里得到一些答案后——我画了一张可以帮助每个人的图画——我想。

https://i.stack.imgur.com/A5F7P.jpg 在此处输入图像描述

4

3 回答 3

20

您提到的措辞看起来像我经常使用的措辞。规范说明了这一点:

  • 对易失性字段的读取称为易失性读取。易失性读取具有“获取语义”;也就是说,它保证在指令序列中对内存的任何引用之前发生。
  • 对易失性字段的写入称为易失性写入。易失性写入具有“释放语义”;也就是说,它保证发生在指令序列中写指令之前的任何内存引用之后。

但是,我通常使用您在问题中引用的措辞,因为我想将重点放在可以移动指令的事实上。您引用的措辞和规范是等效的。

我将举几个例子。在这些示例中,我将使用一个特殊的符号,使用 ↑ 箭头表示释放栅栏,使用 ↓ 箭头表示获取栅栏。不允许任何其他指令向下飘过 ↑ 箭头或向上飘过 ↓ 箭头。把箭头想象成排斥一切的东西。

考虑以下代码。

static int x = 0;
static int y = 0;

static void Main()
{
  x++
  y++;
}

重写它以显示各个指令看起来像这样。

static void Main()
{
  read x into register1
  increment register1
  write register1 into x
  read y into register1
  increment register1
  write register1 into y
}

现在,因为在此示例中没有内存屏障,只要执行线程感知的逻辑顺序与物理顺序一致, C# 编译器、JIT 编译器或硬件就可以自由地以多种不同方式对其进行优化。这是一种这样的优化。注意读写是如何被交换的xy

static void Main()
{
  read y into register1
  read x into register2
  increment register1
  increment register2
  write register1 into y
  write register2 into x
}

现在这一次将这些变量更改为volatile. 我将使用我们的箭头符号来标记内存障碍。注意读取和写入的顺序是如何x保留y的。这是因为指令不能越过我们的障碍(用↓和↑箭头表示)。现在,这很重要。请注意,指令的增量和写入x仍允许向下浮动,而读取的指令仍允许y向上浮动。这仍然有效,因为我们使用的是半栅栏。

static volatile int x = 0;
static volatile int y = 0;

static void Main()
{
  read x into register1
  ↓    // volatile read
  read y into register2
  ↓    // volatile read
  increment register1
  increment register2
  ↑    // volatile write
  write register1 into x
  ↑    // volatile write
  write register2 into y
}

这是一个非常微不足道的例子。在此处查看我的答案,以了解如何volatile在双重检查模式中有所作为的重要示例。我使用了与此处相同的箭头符号,以便轻松可视化正在发生的事情。

现在,我们也有了可以使用的Thread.MemoryBarrier方法。它会生成一个完整的围栏。因此,如果我们使用箭头符号,我们也可以想象它是如何工作的。

考虑这个例子。

static int x = 0;
static int y = 0;

static void Main
{
  x++;
  Thread.MemoryBarrier();
  y++;
}

如果我们要像以前一样显示各个指令,那么它看起来像这样。请注意,现在完全阻止了指令移动。在不影响指令逻辑顺序的情况下,真的没有其他方法可以执行它。

static void Main()
{
  read x into register1
  increment register1
  write register1 into x
  ↑    // Thread.MemoryBarrier
  ↓    // Thread.MemoryBarrier
  read y into register1
  increment register1
  write register1 into y
}

好的,再举一个例子。这次让我们使用VB.NET。VB.NET 没有volatile关键字。那么我们如何在 VB.NET 中模拟 volatile 读取呢?我们将使用Thread.MemoryBarrier. 1

Public Function VolatileRead(ByRef address as Integer) as Integer
  Dim local = address
  Thread.MemoryBarrier()
  Return local
End Function

这就是我们的箭头符号的样子。

Public Function VolatileRead(ByRef address as Integer) as Integer
  read address into register1
  ↑    // Thread.MemoryBarrier
  ↓    // Thread.MemoryBarrier
  return register1
End Function

重要的是要注意,由于我们要模拟 volatile 读取,因此Thread.MemoryBarrier必须在实际读取之后放置对的调用。不要陷入认为 volatile 读取意味着“新读取”而 volatile 写入意味着“已提交写入”的陷阱。这不是它的工作方式,当然也不是规范所描述的。

更新:

参考图像。

等待!我正在验证所有写入都已完成!

等待!我正在验证所有消费者都获得了当前值!

这就是我说的陷阱。这些陈述并不完全准确。是的,在硬件级别实现的内存屏障可能会同步缓存一致性行,因此上述陈述可能对所发生的情况有所准确。但是,volatile无非是限制指令的移动。该规范没有说明从内存中加载值或将其存储到内存屏障所在位置的内存中。


1当然,Thread.VolatileRead内置函数已经存在。你会注意到它的实现与我在这里所做的完全一样。

于 2012-05-14T20:57:41.273 回答
3

从另一种方式开始:

当您阅读 volatile 字段时,什么是重要的?之前对该字段的所有写入都已提交。

写入 volatile 字段时什么是重要的?所有以前的读取都已经有了它们的值。

然后尝试验证获取栅栏和释放栅栏在这些情况下是否有意义。

于 2012-05-14T19:19:50.157 回答
1

为了更容易理解这一点,让我们假设一个可以进行任何重新排序的内存模型。

让我们看一个简单的例子。假设这个 volatile 字段:

volatile int i = 0;

和这个读写序列:

1. int a = i;
2. i = 3;

对于读取 的指令 1,i生成一个获取栅栏。这意味着写入指令 2i不能与指令 1 一起重新排序,因此a在序列末尾不可能是 3。

现在,当然,如果您考虑单个线程,则上述内容没有多大意义,但是如果另一个线程要对相同的值进行操作(假设a是全局的):

thread 1               thread 2
a = i;                 b = a;
i = 3;

在这种情况下,您会认为线程 2 不可能获得 3 的值b(因为它会获得a赋值之前或之后的值a = i;)。但是,如果对 get 的读取和写入进行i重新排序,则可能会b得到值 3。在这种情况下,i如果您的程序正确性取决于不变为b3,则必须设置 volatile。

免责声明:以上示例仅用于理论目的。除非编译器完全疯狂,否则它不会进行可能为变量创建“错误”值的重新排序(即a即使i不是易失性也不能为 3)。

于 2012-05-14T19:21:42.473 回答