关于volatile和内存屏障,我不理解的是

6

循环中的提升易失性读取

我已经在很多地方读到过,一个易失性变量不能从一个循环或if语句提升,但是我在C#规范中没有找到这个被提到。这是一个隐藏功能吗?

C#中所有写操作都是易失性的

这是否意味着所有写操作都具有相同的特性,无论使用volatile关键字与否?例如,C#中普通的写操作具有释放语义吗?所有写操作都会刷新处理器的存储缓冲区吗?

释放语义

这是一种正式的说法,表示当执行易失性写入时,处理器的存储缓冲区会被清空吗?

获取语义

这是一种正式的说法,表示不应该将变量加载到寄存器中,而是每次从内存中获取吗?

这篇文章中,Igoro提到了“线程缓存”。我完全理解这是想象出来的,但他实际上是在指:

  1. 处理器存储缓冲区
  2. 将变量加载到寄存器中,而不是每次从内存中获取
  3. 某种类型的处理器缓存(这是L1和L2等吗)
或者这只是我的想象?

延迟写入

我已经在很多地方读到过,写操作可能会被延迟。这是由于重排序和存储缓冲区造成的吗?

Memory.Barrier

我知道一个副作用是在JIT将IL转换为汇编时调用“lock or”,这就是为什么Memory.Barrier可以解决在fx这个例子中while循环中的延迟写入到主存的问题。

static void Main()
{
  bool complete = false; 
  var t = new Thread (() =>
  {
    bool toggle = false;
    while (!complete) toggle = !toggle;
  });
  t.Start();
  Thread.Sleep (1000);
  complete = true;
  t.Join();        // Blocks indefinitely
}

但这总是吗?调用Memory.Barrier是否总是会刷新存储缓冲区并将更新后的值提取到处理器缓存中?我理解完整的变量不会被提升到寄存器中,每次都会从处理器缓存中提取,但由于调用了Memory.Barrier,因此处理器缓存会得到更新。

我现在的理解是否正确,或者我对易失性和Memory.Barrier有一定的理解?


7
根据我的数数,你在那里用了11个问号 :) 那个有问题的问号是哪一个? - Alex
@poke 你是在发布模式下运行吗? - dcastro
2
@poke 你是在调试模式下运行它吗?并且已经编译成发布模式了吗? - mslot
我现在处于发布模式。不过没关系,似乎即使在发布模式下运行,Visual Studio也会防止它挂起。当单独启动可执行文件时,它会(正确地)阻塞。 - poke
@poke,实际上我也无法重现它...你能把聊天记录链接给我吗? - dcastro
显示剩余6条评论
1个回答

13

这个说得有点复杂。我会从你的一些问题开始,并更新我的回答。


循环提升易失性变量

我在多个地方读过,循环中或if中无法提升易失性变量,但我在C#规范中找不到任何提到这一点的地方。这是一个隐藏的特性吗?

MSDN说:“声明为volatile的字段不受编译器通过单个线程访问的优化的影响”。这是一种比较宽泛的陈述,但它包括将变量从循环中提升或“提升”的情况。


C#中所有写操作都是易失的

这是否意味着所有写操作都具有与使用易失关键字相同的属性?例如,C#中的普通写操作具有释放语义吗?所有写操作都刷新处理器的存储缓冲区吗?

普通的写操作不是易失的。它们具有释放语义,但它们不会刷新CPU的写缓冲区。至少根据规范是这样的。

来自Joe Duffy的CLR 2.0内存模型

规则2:所有存储操作具有释放语义,即一个存储操作后不得移动任何加载或存储操作。

我读过几篇文章声称C#中的所有写操作都是易失的(就像你链接的那个),但这是一种常见的误解。按照官方说法(C#内存模型的理论和实践,第2部分):

因此,作者可能会说:“在.NET 2.0内存模型中,所有写操作都是易失的——即使是对非易失性字段的写入。”(...)ECMA C#规范不能保证这种行为,并且因此可能不适用于未来版本的.NET Framework和未来体系结构(事实上,这在 .NET Framework 4.5 on ARM 中并不适用)。


释放语义

这是否是正式表达处理器的存储缓冲区在进行易失性写入时被清空?

不,这是两个不同的概念。如果指令具有“释放语义”,那么任何存储/加载指令都不会在该指令之下移动。该定义与清空写缓冲区无关,它只涉及指令重新排序。


延迟写入

我在多个地方读到过写入可以被延迟。这是因为重排序和存储缓冲区吗?

是的。写指令可能会被编译器、Jitter或CPU本身延迟/重新排序。


因此,volatile写操作具有两个属性:释放语义和存储缓冲区刷新。

有点像。我更喜欢这样思考:

C#规范保证了volatile关键字的一个属性:读取具有获取语义,写入具有释放语义。通过发出必要的释放/获取栅栏来实现这一点。

而Microsoft的C#实现则增加了另一个属性:读取将是最新的,并且写入将立即刷新到内存并对其他处理器可见。为此,编译器会发出一个OpCodes.Volatile,而Jitter则会接收这个信息并告诉处理器不要将此变量存储在其寄存器中。

这意味着不保证立即性的另一个C#实现将是完全有效的实现。


内存屏障

bool complete = false; 
var t = new Thread (() =>
{
    bool toggle = false;
    while (!complete) toggle = !toggle;
});
t.Start();
Thread.Sleep(1000);
complete = true;
t.Join();     // blocks

但是否总是这样呢?调用 Memory.Barrier() 方法会不会始终将存储缓冲区刷新,并获取更新后的值到处理器缓存中呢?

这里有一个提示:试着将自己抽象出来,不要纠结于诸如刷新存储缓冲区或者直接从内存中读取的概念。Memory Barrier(或全栅栏)的概念与前面两个概念完全没有关系。

Memory Barrier 的唯一目的就是确保栅栏下方的存储/加载指令不会被移动到栅栏上方,反之亦然。如果 C# 的 Thread.MemoryBarrier() 恰好刷新了未完成的写操作,那么您应该将其视为一个副作用,而不是主要意图。

现在,让我们来重点讨论。您发布的代码(在 Release 模式下编译并且没有调试器的情况下运行时会阻塞)可以通过在 while 块内的任何位置引入全栅栏来解决。为什么?首先,让我们将循环展开。以下是前几次迭代的样子:

if(complete) return;
toggle = !toggle;

if(complete) return;
toggle = !toggle;

if(complete) return;
toggle = !toggle;
...

由于complete没有被标记为volatile,也没有使用任何屏障,编译器和CPU允许移动对complete字段的读取操作。实际上,CLR的内存模型(参见规则6)允许在合并相邻的加载时删除!所以,可能会出现这种情况:

if(complete) return;
toggle = !toggle;
toggle = !toggle;
toggle = !toggle;
...

请注意,这与将读取操作提升出循环在逻辑上是等价的,编译器可能会这样做。

通过在toggle =! toggle之前或之后引入一个完整的栅栏,您可以防止编译器将读取操作向上移动并将它们合并在一起。

if(complete) return;
toggle = !toggle;
#FENCE
if(complete) return;
toggle = !toggle;
#FENCE
if(complete) return;
toggle = !toggle;
#FENCE
...

总之,解决这些问题的关键是确保指令按照正确的顺序执行。这与其他处理器何时看到一个处理器的写入操作无关。


CLR 2.0的内存模型和C#规范中描述的内存模型并不相同。我认为这个问题是在问后者,而不是前者。 - svick
因此,volatile写入具有两个属性:释放语义和存储缓冲区刷新。我一直将释放语义和刷新视为一个整体。 - mslot
1
@mslot 我在我的答案中(末尾)添加了一些关于您发布的代码以及为什么内存屏障会修复它的内容。我还添加了一条注释,涉及到您观察到的“在C#中所有写入都是易失性的”。我希望能够解决您的一些疑虑 :) - dcastro
那么在取消循环展开中,通过使用 Memory.Barrier,您可以确保加载始终在未从寄存器中提取的变量上执行? - mslot
@mslot 我的观点是:如果发生了那种情况,那就是一个实现细节。内存屏障的重点是防止指令越过栅栏。在这种情况下,栅栏防止读取被向后移动(这在逻辑上等同于读取缓存值 - 再次强调,这是一个细节)。 - dcastro
显示剩余7条评论

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