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

Iam*_*mIC 15 c# multithreading .net-4.0 compiler-optimization

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

我知道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
    }
}
Run Code Online (Sandbox Code Playgroud)

代码表现如预期.但是,如果我取消注释//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 
Run Code Online (Sandbox Code Playgroud)

IL与Yield()(不会停滞):

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
Run Code Online (Sandbox Code Playgroud)

Bri*_*eon 11

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

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

  • 编译器(如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;
}
Run Code Online (Sandbox Code Playgroud)

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