易失性读取/写入和 Thread.MemoryBarrier 排序

Volatile Reads / Writes and Thread.MemoryBarrier Ordering

我正在努力思考内存屏障和易失性 reads/writes 的微妙之处。我正在阅读 Joseph Albahari 的线程文章:

http://www.albahari.com/threading/part4.aspx

并在 read/write 之前何时需要内存屏障以及之后何时需要内存屏障的问题上绊倒了。在"full fences"段的这段代码中,他在每次写入之后和每次读取之前放置了一个内存屏障:

class Foo
{
  int _answer;
  bool _complete;

  void A()
  {
    _answer = 123;
    Thread.MemoryBarrier();    // Barrier 1
    _complete = true;
    Thread.MemoryBarrier();    // Barrier 2
  }

  void B()
  {
    Thread.MemoryBarrier();    // Barrier 3
    if (_complete)
    {
      Thread.MemoryBarrier();       // Barrier 4
      Console.WriteLine (_answer);
    }
  }
}

他继续解释:

Barriers 1 and 4 prevent this example from writing “0”. Barriers 2 and 3 provide a freshness guarantee: they ensure that if B ran after A, reading _complete would evaluate to true.

问题 #1: 我对障碍 1 和 4 没有问题,因为它会阻止重新排序跨越这些障碍。我不完全理解为什么障碍 2 和 3 是必要的。有人可以解释一下,特别是考虑到 Thread class 中如何实现易失性读写(接下来解释)吗?

现在我真正开始感到困惑的是,这是 Thread.VolatileRead/Write():

的实际实现
[MethodImplAttribute(MethodImplOptions.NoInlining)]
public static void VolatileWrite (ref int address, int value)
{
  MemoryBarrier(); address = value;
}

[MethodImplAttribute(MethodImplOptions.NoInlining)]
public static int VolatileRead (ref int address)
{
  int num = address; MemoryBarrier(); return num;
}

如您所见,与前面的示例相比,内置易失性函数在每次写入之前(而不是之后)和每次读取之后(而不是之前)放置内存屏障。因此,如果我们基于内置的 volatile 函数用等效版本重写前面的示例,它将看起来像这样:

class Foo
{
  int _answer;
  bool _complete;

  void A()
  {
    Thread.MemoryBarrier();    // Barrier 1
    _answer = 123;
    Thread.MemoryBarrier();    // Barrier 2
    _complete = true;
  }

  void B()
  {
    if (_complete)
    {
      Thread.MemoryBarrier();    // Barrier 3
      Console.WriteLine (_answer);
      Thread.MemoryBarrier();       // Barrier 4
    }
  }
}

问题 #2: 两个 Foo class 在功能上是等价的吗?为什么或者为什么不?如果需要障碍 2 和 3(在第一个 Foo class 中)来保证写入值和读取实际值,那么 Thread.VolatileXXX 方法不会有点无用吗?

Whosebug 上有几个类似的问题,接受的答案如 "barrier 2 ensures the write to _complete isn't cached",但其中 none 解决了为什么 Thread.VolatileWrite() 在写入之前放置内存屏障,如果是这样的话,以及如果 Thread.VolatileRead() 将内存屏障放在读取之后但保证最新值,为什么需要屏障 3。我认为这是最让我失望的地方。

更新:

好吧,经过更多的阅读和思考,我有了一个理论,并用我认为可能相关的属性更新了源代码。我认为 Thread.VolatileRead/Write 方法中的内存屏障根本不是为了确保 "freshness" 的值,而是为了强制执行重新排序保证。在读取之后和写入之前放置屏障可确保在任何读取之前不会移动任何写入(反之亦然)。

据我所知,x86 上的所有写入都通过使其他内核上的缓存行无效来保证缓存一致性,因此只要值未缓存在寄存器中,"freshness" 就可以得到保证。我的理论 VolatileRead/Write 确保该值不在寄存器中,这可能有点偏离,但我 认为 我在正确的轨道上,他们指望的是一个 .NET 实现细节,如果它们被标记为 MethodImplOptions.NoInlining(正如您在上面看到的那样),那么该值将需要传递给 to/from 方法,而不是作为局部变量内联,因此必须从 memory/cache 而不是直接通过寄存器访问,因此无需在写入之后和读取之前使用额外的内存屏障。我不知道情况是否如此,但这是我能看到它正常工作的唯一方式。

谁能证实或否认是这种情况?

I don't think the memory barriers in the Thread.VolatileRead/Write methods are there to ensure "freshness" of the values at all, but rather to enforce the reordering guarantees.

没错。

Putting the barrier after reads and before writes ensures that no writes will be moved before any reads (but not vice versa).

完整的内存屏障同时具有获取和释放语义,它可以防止先前的内存访问被重新排序到屏障之后,也防止后续的内存访问被重新排序到屏障之前。

Can anyone confirm or deny that this is the case?

对于在 x86 中 Microsoft 的 .NET 实现中写入,您可能是正确的,但同样不适用于读取。可以通过 JIT 编译器(可能不使用 no-inlining 属性)或 CPU(即使使用 no-inlining 属性)对先前访问和内存屏障之间的读取进行重新排序。

然而,这不应改变 运行 代码看到的内容,尽管读取可能看不到 最新鲜 值。

int value = 0;
bool done = false;

// in thread 1
value = 123;
Thread.VolatileWrite(ref done, true);

// in thread 2
Thread.SpinUntil(() => Thread.VolatileRead(ref done));
Console.WriteLine(value); // guaranteed 123 due to the memory barrier

在内存模型较弱的其他体系结构中,可以在后续内存访问之后对写入进行重新排序,在极端情况下,直到下一个内存屏障,它才可能对其他线程不可见。不过,对于循环,这不是什么大问题。

无论如何,我的建议是不要使用 Thread.VolatileReadThread.VolatileWrite

读取和写入 volatile 字段,Volatile.ReadVolatile.Write 方法提供正确的语义。

虽然 Volatile.ReadVolatile.Write 方法是在 C# 中实现的,就像 Thread.VolatileReadThread.VolatileWrite 一样,但 CLR 用实际的 volatile read/write语义。