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

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

我也知道编译器优化有些“不可预测”。以下代码将说明由于编译器优化而导致的停顿(在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也将导致循环退出(以其显示的模式)。

我的问题是:为了确定何时隐式执行 volatile 读取,编译器遵循哪些规则?为什么我仍然可以用我认为是奇怪的方法退出循环?

编辑

所示代码的IL(显示为stalls):
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

最佳答案



首先,移动指令的不仅仅是编译器。导致指令重新排序的三大 Actor 是:

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

  • 硬件级别的规则有些繁琐,因为通常情况下它们记录得很好。但是,在运行时和编译器级别,存在内存模型规范,这些规范提供了如何对指令进行重新排序的约束,但是要由实现者来决定他们要如何积极地优化代码以及他们要紧迫地走多远。关于内存模型约束。

    例如,CLI的ECMA规范提供了相当弱的保证。但是Microsoft决定在.NET Framework CLR中加强这些保证。除了一些博客文章外,我还没有看到有关CLR遵守的规则的大量正式文档。当然,Mono可能会使用不同的规则集,这可能会也可能不会使它更接近ECMA规范。当然,只要仍考虑正式的ECMA规范,在将来的发行版中更改规则可能会有一些自由。

    综上所述,我有几点看法:
  • 使用Release配置进行编译更有可能导致指令重新排序。
  • 更简单的方法更有可能将其指令重新排序。
  • 将读取从循环内部提升到循环外部是典型的重新排序优化类型。



  • 这是因为这些“奇怪的措施”正在做以下两件事之一:
  • 生成隐式内存屏障
  • 规避了编译器或运行时执行某些优化的能力

  • 例如,如果方法中的代码过于复杂,则可能会阻止JIT编译器执行某些对指令重新排序的优化。您可以将其视为某种复杂的方法也不会内联的方式。

    同样,像Thread.YieldThread.Sleep这样的东西也会创建隐式的内存屏障。我已经开始列出here这样的机制。我敢打赌,如果您在代码中加入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,然后再进行读取。但是,a,您的直觉是错误的!规范说, volatile 读取会产生获取栅栏障碍。按照上面我给您的定义,这意味着对MemoryBarrier的调用必须在读取address之后,以防止在其之前移动其他读取和写入操作。您会看到 volatile 读取并不完全是关于“新鲜”读取的。这是关于防止指令移动。这令人难以置信。我知道。

    10-02 02:53
    查看更多