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

时间:2011-12-07 11:17:06

标签: 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
    }
}

代码的行为与预期一致。但是,如果我取消注释//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 

IL with 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

4 个答案:

答案 0 :(得分:11)

  

编码器遵循的规则是什么时候确定   implicity执行易失性读取?

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

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

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

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

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

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

为什么我仍然能够以我认为的方式退出循环   奇怪的措施?

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

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

例如,如果方法内部的代码过于复杂,则可能会阻止JIT编译器执行重新排序指令的某些优化。你可以认为它有点像复杂的方法也没有内联。

此外,Thread.YieldThread.Sleep之类的内容会产生隐含的内存障碍。我已经开始列出这样的机制here。我敢打赌,如果你在代码中加入Console.WriteLine调用,它也会导致循环退出。我还看到“非终止循环”示例在.NET Framework的不同版本中表现不同。例如,我敢打赌如果你在1.0中运行该代码就会终止它。

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

<强>更新

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

  • 获取围栏:一个记忆障碍,其他读取&amp;在围栏之前,不允许写入
  • 释放栅栏:一种记忆屏障,其他读取&amp;在围栏之后,不允许写入

因此,当您看到对Thread.MemoryBarrier的来电时,它会阻止所有读取&amp;从屏障上方或下方移动写入。它还会发出所需的CPU特定指令。

如果您查看Thread.VolatileRead的代码,您将会看到这些内容。

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

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

答案 1 :(得分:2)

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

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

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

    do
    {
       System.Threading.Thread.MemoryBarrier();

        if (data._loop != 1)
        {
            break;
        }
    } while (true);

答案 2 :(得分:2)

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

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

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

答案 3 :(得分:0)

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

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

为了解决您的实际问题,我们只会对案例2中应该发生的事情做出实际规则。