Thread.MemoryBarrier()与PoOP的Bug的解释

时间:2014-03-18 10:40:55

标签: c# .net multithreading memory-barriers

好的,所以在阅读了Albahari在C#中的线程后,我试图了解Thread.MemoryBarrier()和乱序处理。

根据Brian Gideon对Why we need Thread.MemoerBarrier()的回答,他提到以下代码会导致程序在发布模式下无限循环,并且不附带调试器。

class Program
{
    static bool stop = false;

    public static void Main(string[] args)
    {
        var t = new Thread(() =>
        {
            Console.WriteLine("thread begin");
            bool toggle = false;
            while (!stop)
            {
                // Thread.MemoryBarrier() or Console.WriteLine() fixes issue
                toggle = !toggle;
            }
            Console.WriteLine("thread end");
        });
        t.Start();
        Thread.Sleep(1000);
        stop = true;
        Console.WriteLine("stop = true");
        Console.WriteLine("waiting...");
        t.Join();
    }
}

我的问题是,为什么在没有添加Thread.MemoryBarrier(),甚至是while循环中的Console.WriteLine()来解决问题?

我猜这是因为在多处理器机器上,线程运行时有自己的值缓存,并且永远不会检索更新的stop值,因为它在缓存中有值?

或者是主线程没有将此提交给内存?

为什么Console.WriteLine()修复此问题?是因为它还实现了MemoryBarrier吗?

4 个答案:

答案 0 :(得分:3)

只要任何更改对于单个线程一致,编译器和CPU就可以通过以他们认为合适的任何方式重新排序来自由优化代码。这就是您在单线程程序中永远不会遇到问题的原因。

在您的代码中,您有两个使用stop标志的线程。编译器或CPU可以选择将值缓存在CPU寄存器中,因为在您创建的线程中,它可以检测到您没有在线程中写入它。你需要的是告诉编译器/ CPU在另一个线程中修改变量的一些方法,因此它不应该缓存该值但应该从内存中读取它。

有几种简单的方法可以做到这一点。一个是在stop语句中包含对lock变量的所有访问权限。这将创建一个完整的屏障并确保每个线程都能看到当前值。另一个是使用Interlocked类来读/写变量,因为这也构成了一个完整的障碍。

还有某些方法,例如WaitJoin,它们也会设置内存屏障以防止重新排序。 Albahari书中列出了这些方法。

答案 1 :(得分:2)

它不会解决任何问题。这是一个假的修复,在生产代码中相当危险,因为它可能有效,或者它可能无法正常工作。

核心问题在于这一行

static bool stop = false;

停止while循环的变量不是易失性的。这意味着它可能始终从内存中读取也可能不读取。它可以被缓存,因此只有最后一个读取值被提供给系统(可能不是实际的当前值)。

此代码

// Thread.MemoryBarrier() or Console.WriteLine() fixes issue

可能会或可能不会在不同平台上修复问题。内存屏障或控制台写入恰好强制应用程序读取特定系统上的新值。在其他地方可能不一样。


此外,volatileThread.MemoryBarrier()仅提供保证,这意味着它们不能100%保证读取值始终是所有系统上的最新值和CPU。

Eric Lippert says

  

volatile读取的真正语义   写作比我在这里概述的要复杂得多;在   事实上,他们并不能保证每个处理器都能阻止它   正在进行和更新主存储器的缓存。相反,他们提供   关于读取和读取之前和之后内存访问方式的较弱保证   可以观察到写入相对于彼此进行排序。   某些操作,例如创建新线程,输入锁定或   使用Interlocked系列方法之一引入更强   关于观察订购的保证。如果你想要更多细节,   阅读C#4.0规范的第3.10和10.5.3节。

答案 2 :(得分:1)

该示例与无序执行没有任何关系。它仅显示可能的编译器优化远离stop访问的影响,应通过简单地标记变量volatile来解决。有关更好的示例,请参阅Memory Reordering Caught in the Act

答案 3 :(得分:1)

让我们从一些定义开始。 volatile关键字在读取时生成 acquire-fence ,在写入时生成 release-fence 。这些定义如下。

  • acquire-fence:一种内存屏障,其中不允许其他读写操作在围栏之前移动。
  • release-fence:一种内存屏障,在屏障后不允许其他读写操作。

方法Thread.MemoryBarrier生成全栅栏。这意味着它会生成 acquire-fence release-fence 。令人沮丧的是,MSDN说了这一点。

  

按如下方式同步内存访问:处理器执行   当前线程无法以内存方式重新排序指令   在调用MemoryBarrier之前访问内存后执行   跟随对MemoryBarrier的调用的访问。

解释这导致我们相信它只会生成 release-fence 。那是什么?一个完整的栅栏或半栅栏?这可能是另一个问题的主题。我打算工作的假设是它是一个完整的围栏,因为很多聪明的人都提出了这个要求。但是,更令人信服的是,BCL本身使用Thread.MemoryBarrier就好像它产生了一个全围栏。所以在这种情况下,文档可能是错误的。更有趣的是,该语句实际上意味着在调用之前的指令可以以某种方式夹在调用之后的调用和指令之间。那太荒谬了。我开玩笑说(但不是真的),微软可能会让律师审查有关线程的所有文档。我相信他们的法律技能可以在该领域得到很好的利用。

现在我将介绍一个箭头符号来帮助说明行动中的栅栏。 ↑箭头表示 release-fence ,↓箭头表示 acquire-fence 。把箭头想象成沿箭头方向推开记忆存取。但是,这很重要,内存访问可以超越尾部。阅读上面围栏的定义,并说服自己,箭头直观地代表了这些定义。

接下来,我们将仅分析循环,因为这是代码中最重要的部分。要做到这一点,我将前往unwind the loop。这是它的样子。

LOOP_TOP:

// Iteration 1
read stop into register
jump-if-true to LOOP_BOTTOM
↑
full-fence // via Thread.MemoryBarrier
↓
read toggle into register
negate register
write register to toggle
goto LOOP_TOP

// Iteration 2
read stop into register
jump-if-true to LOOP_BOTTOM
↑
full-fence // via Thread.MemoryBarrier
↓
read toggle into register
negate register
write register to toggle
goto LOOP_TOP

...

// Iteration N
read stop into register
jump-if-true to LOOP_BOTTOM
↑
full-fence // via Thread.MemoryBarrier
↓
read toggle into register
negate register
write register to toggle
goto LOOP_TOP

LOOP_BOTTOM:

请注意,对Thread.MemoryBarrier的调用限制了某些内存访问的移动。例如,toggle的读取在读取stop之前无法移动,反之亦然,因为不允许那些内存访问通过箭头移动。

现在想象如果全围栏被删除会发生什么。 C#编译器,JIT编译器或硬件现在在移动指令时有更多的自由。特别是现在允许提升优化,正式地称为loop invariant code motion。基本上,编译器检测到stop永远不会被修改,因此读取会从循环中冒出来。它现在有效地缓存到寄存器中。如果内存屏障到位,则读取必须通过箭头向上推,规范明确禁止这样做。如果您像上面那样解开循环,这更容易可视化。请记住,对Thread.MemoryBarrier的调用将在循环的每次迭代中进行,因此您不能简单地得出只有迭代会发生什么的结论。 / p>

精明的读者会注意到编译器可以自由地交换togglestop的读取,以便stop获得"刷新"在循环的末尾而不是开头,但这与循环的上下文行为无关。它具有完全相同的语义并产生相同的结果。

  

我的问题是为什么,不添加Thread.MemoryBarrier(),甚至是   while循环中的Console.WriteLine()修复了这个问题吗?

因为内存屏障限制了编译器可以执行的优化。它将禁止循环不变代码运动。假设Console.WriteLine产生的记忆障碍可能是真的。如果没有内存障碍,C#编译器,JIT编译器或硬件可以自由地将stop的读取提升到循环本身之外。

  

我猜这是因为在多处理器机器上,线程   使用自己的值缓存运行,并且永远不会检索更新的值   stop的值因为它在缓存中有值吗?

简而言之......是的。但请记住,它与处理器的数量无关。这可以用单个处理器来演示。

  

还是主线程没有将它提交给内存?

没有。主线程将提交写入。对Thread.Join的调用确保了因为它会创建一个内存屏障,禁止写入的移动落在连接之下。

  

为什么Console.WriteLine()修复此问题?是因为它也是   实现了MemoryBarrier?

是。它可能会产生记忆障碍。我一直在保留一个内存屏障生成器列表here