何时使用volatile来抵消C#中的编译器优化

我花了很多周时间在C#4.0中进行multithreading编码。 但是,有一个问题对我来说仍然没有答案。

我知道volatile关键字阻止编译器将变量存储在寄存器中,从而避免无意中读取过时值。 写入在.Net中始终是易变的,因此任何说明它也避免了stales写入的文档都是多余的。

我也知道编译器优化有些“不可预测”。 以下代码将说明由于编译器优化而导致的停顿(在VS之外运行发布编译时):

class Test { public struct Data { public int _loop; } public static Data data; public static void Main() { data._loop = 1; Test test1 = new Test(); new Thread(() => { data._loop = 0; } ).Start(); do { if (data._loop != 1) { break; } //Thread.Yield(); } while (true); // will never terminate } } 

代码表现如预期。 但是,如果我取消注释//Thread.Yield(); 行,然后循环将退出。

此外,如果我在do循环之前放入Sleep语句,它将退出。 我不明白。

当然,用volatile来装饰_loop也会导致循环退出(以其显示的模式)。

我的问题是:编译器遵循的规则是什么,以确定何时隐含执行易失性读取? 为什么我仍然可以通过我认为奇怪的措施退出循环?

编辑

IL代码如图所示(档位):

 L_0038: ldsflda valuetype ConsoleApplication1.Test/Data ConsoleApplication1.Test::data L_003d: ldfld int32 ConsoleApplication1.Test/Data::_loop L_0042: ldc.i4.1 L_0043: beq.s L_0038 L_0045: ret 

带有Yield()的IL(不会失速):

 L_0038: ldsflda valuetype ConsoleApplication1.Test/Data ConsoleApplication1.Test::data L_003d: ldfld int32 ConsoleApplication1.Test/Data::_loop L_0042: ldc.i4.1 L_0043: beq.s L_0046 L_0045: ret L_0046: call bool [mscorlib]System.Threading.Thread::Yield() L_004b: pop L_004c: br.s L_0038 

编译器遵循的规则是什么,以确定何时隐含执行易失性读取?

首先,不只是编译器移动指令。 导致教学重新排序的三大演员是:

  • 编译器(如C#或VB.NET)
  • 运行时(如CLR或Mono)
  • 硬件(如x86或ARM)

硬件级别的规则稍微削减和干燥,因为它们通常记录得很好。 但是,在运行时和编译器级别,存在内存模型规范,这些规范提供了如何重新排序指令的约束,但是由实现者决定他们希望如何积极地优化代码以及他们希望如何接近线路关于内存模型约束。

例如,CLI的ECMA规范提供了相当弱的保证。 但微软决定收紧.NET Framework CLR中的这些保证。 除了一些博客文章,我还没有看到关于CLR遵守的规则的正式文档。 当然,Mono可能会使用一组不同的规则,这些规则可能会也可能不会使它更接近ECMA规范。 当然,只要仍然考虑正式的ECMA规范,在未来版本中更改规则可能会有一些自由。

所有这些都说我有一些观察:

  • 使用Release配置进行编译更有可能导致指令重新排序。
  • 更简单的方法更有可能重新排序其指令。
  • 将循环内部的读取提升到循环外部是典型的重新排序优化。

为什么我仍然可以通过我认为奇怪的措施退出循环?

这是因为那些“奇怪的措施”正在做两件事之一:

  • 产生隐含的记忆障碍
  • 绕过编译器或运行时执行某些优化的能力

例如,如果方法内的代码过于复杂,则可能会阻止JIT编译器执行重新排序指令的某些优化。 你可以把它想象成复杂的方法也不会被内联。

此外, Thread.YieldThread.Sleep类的东西会创建隐式内存障碍。 我在这里开始列出这样的机制。 我打赌如果你在你的代码中放入一个Console.WriteLine调用它也会导致循环退出。 我还看到“非终止循环”示例在.NET Framework的不同版本中表现不同。 例如,我打赌如果你在1.0中运行该代码它会终止。

这就是为什么使用Thread.Sleep来模拟线程交错实际上可能会掩盖内存屏障问题。

更新:

阅读完一些评论后,我想你可能会对Thread.MemoryBarrier实际上做了什么感到困惑。 它的作用是创造一个全栅栏屏障。 这究竟是什么意思? 全围栏屏障是两个半围栏的组成:一个获取围栏和一个释放围栏。 我现在要定义它们。

  • 获取栅栏:一种内存屏障,其中不允许其他读写操作在栅栏之前移动。
  • 释放栅栏:一种内存屏障,在栅栏不允许其他读写操作。

因此,当您看到对Thread.MemoryBarrier的调用时,它将阻止所有读取和写入在屏障的上方或下方移动。 它还将发出所需的任何CPU特定指令。

如果你看一下Thread.VolatileRead的代码,你会看到这些。

 public static int VolatileRead(ref int address) { int num = address; MemoryBarrier(); return num; } 

现在你可能想知道为什么MemoryBarrier调用是实际读取之后。 你的直觉可能会告诉你,要获得一个“新鲜”的address读取,你需要在读取之前调用MemoryBarrier 。 但是,唉,你的直觉是错的! 规范说,易失性读取应该产生一个获取栅栏屏障。 根据我上面给出的定义,这意味着对MemoryBarrier的调用必须读取address 之后 ,以防止之前移动其他读取和写入。 你看到易失性读取不是严格意义上的“新鲜”读取。 它是关于防止指令的移动。 这令人难以置信的混乱; 我知道。

您的示例运行未终结(我认为大部分时间)因为可以缓存_loop。

你提到的任何“解决方案”(Sleep,Yield)都会涉及内存障碍,迫使编译器刷新_loop。

最小的解决方案(未经测试):

  do { System.Threading.Thread.MemoryBarrier(); if (data._loop != 1) { break; } } while (true); 

它不仅是编译器的问题,它也可以是CPU的问题,它也可以自己进行优化。 当然,消费者CPU通常没有那么多的自由,通常编译器是上述情况的罪魁祸首。

完整的栅栏可能太重了,无法进行单个易失性读取。

话虽如此,可以在此处找到可以进行优化的详细解释: http : //igoro.com/archive/volatile-keyword-in-c-memory-model-explained/

在硬件层面似乎有很多关于内存障碍的讨论。 记忆围栏在这里无关紧要。 很高兴告诉硬件不要做任何有趣的事情,但它一开始并没有计划这样做,因为你当然要在x86或amd64上运行这个代码。 你不需要在这里使用围栏(尽管可能会发生这种情况,但这种情况非常罕见)。 在这种情况下,您只需要从内存中重新加载值。
这里的问题是JIT编译器很有趣,而不是硬件。

为了强制JIT放弃开玩笑,你需要的东西要么(1)只是简单地欺骗JIT编译器重新加载该变量(但这依赖于实现细节)或者(2)生成内存屏障或读取 – 获取JIT编译器理解的那种(即使没有任何围栏在指令流中结束)。

为了解决您的实际问题,只有关于案例2中应该发生什么的实际规则。