一个C#线程真的能够缓存一个值并忽略其他线程对该值所做的更改吗?

27

这个问题不涉及于竞态条件、原子性或者为什么你应该在代码中使用锁。我已经了解了这些。

更新:我的问题不是“易失性内存存在奇怪的情况”(我知道它存在),我的问题是“.NET运行时是否将其抽象化,使您永远不会看到它”。

请参见http://www.yoda.arachsys.com/csharp/threads/volatility.shtml“字符串属性本身是否线程安全?”上的第一个答案。

(它们实际上是相同的一篇文章,因为其中一篇引用了另一篇。)一个线程设置一个布尔值,另一个线程无限循环读取该布尔值--这些文章声称读取线程可能会缓存旧值并且永远不会读取新值,因此你需要一个锁(或使用volatile关键字)。他们声称以下代码可能会无限循环。 现在我同意锁定变量是个好习惯,但我不能相信.NET运行时真的会像文章所声称的那样忽略内存值的更改。我理解他们关于易失性内存与非易失性内存的讨论,并且我同意在非管理代码中他们有一个有效的观点,但是我不能相信.NET运行时不会正确地将其抽象化,以使以下代码实现您期望的功能。 该文章甚至承认该代码将“几乎肯定”能够工作(虽然不能保证),所以我对这个说法提出质疑。有人能否验证以下代码不总是有效?有人能否得到即使一种情况(也许您无法始终复制它)导致它失败的情况?

class BackgroundTaskDemo
{
    private bool stopping = false;

    static void Main()
    {
        BackgroundTaskDemo demo = new BackgroundTaskDemo();
        new Thread(demo.DoWork).Start();
        Thread.Sleep(5000);
        demo.stopping = true;
    }

    static void DoWork()
    {
         while (!stopping)
         {
               // Do something here
         }
    }
}

1
我同意这似乎应该总是有效。也许PEX可以找到一种打破它的方法。 - Craig Wilson
那甚至无法编译;如果将所有内容都设为静态,该示例可能更接近问题区域(否则,如果JIT执行此操作,则解除引用可能会停止优化)。 - Marc Gravell
更新带有可工作的计数器示例的代码;-p - Marc Gravell
复制成功。必须使用x86机器运行时,发布模式,无调试。在x64架构下,这并不是最优化的。 - TheSoftwareJedi
3个回答

58

重点在于:它可能有效,但并不被规范保证能够有效。人们通常追求的是通过正确的原因而不是编译器、运行时和JIT的机缘巧合而工作的代码,这些东西可能会因框架版本,物理CPU,平台以及x86与x64等因素而发生变化。

理解内存模型是一个非常复杂的领域,我并不自称是专家;但是真正擅长这个领域的人向我保证,您看到的行为不能得到保证。

您可以发布尽可能多的工作示例,但不幸的是,这并不能证明什么,除了“通常有效”。这当然不能证明它保证有效。只需要一个反例就可以证明它无效,但找到它才是问题所在...

不,我手头没有。


更新:提供可重复的反例:

using System.Threading;
using System;
static class BackgroundTaskDemo
{
    // make this volatile to fix it
    private static bool stopping = false;

    static void Main()
    {
        new Thread(DoWork).Start();
        Thread.Sleep(5000);
        stopping = true;


        Console.WriteLine("Main exit");
        Console.ReadLine();
    }

    static void DoWork()
    {
        int i = 0;
        while (!stopping)
        {
            i++;
        }

        Console.WriteLine("DoWork exit " + i);
    }
}

输出:

Main exit

但仍在全速运行;请注意,此时已将 stopping 设置为 true。使用 ReadLine 是为了防止进程终止。优化似乎取决于循环内代码的大小(因此有 i++)。显然,它只适用于“发布”模式。添加 volatile 后一切都可以正常工作。


1
好的回答。我想知道 - 这不是在未来的硬件上保证,还是在现有的硬件上保证?对于一些可能在未来的硬件上无法工作的事情担心似乎有些愚蠢。 - Kent Boogaart
成功重现。必须使用x86机器运行时,发布模式,无调试。在x64架构下,这并不是最优化的。 - TheSoftwareJedi
@TheSoftwareJedi 它不需要是x86机器;它在针对“x86”或针对“任意CPU”+“优先使用32位”目标的x64/.NET 4.5机器上复现,但是如果针对“x64”或针对不带“优先使用32位”的“任意CPI”,则无法复现。 - Marc Gravell
多年过去了,仍然完全相关,感谢您一如既往地提供出色的示例! - mhand
.NET Core 3.1控制台输出DoWork exit 1476942311 Main exit(发布[优化],带或不带32位偏好)Main exit DoWork exit 1469118292(调试[非优化]),但顺序也会被转置。这真的还不能保证吗? - Yarl
显示剩余4条评论

4

这个例子中,原生的x86代码被作为注释包含在内,以演示控制变量('stopLooping')被缓存。

将'stopLooping'更改为volatile即可“修复”它。

这是使用Visual Studio 2008作为发布版本构建的,并且在无需调试的情况下运行。

 using System;

 using System.Threading;

/* A simple console application which demonstrates the need for 
 the volatile keyword and shows the native x86 (JITed) code.*/

static class LoopForeverIfWeLoopOnce
{

    private static bool stopLooping = false;

    static void Main()
    {
        new Thread(Loop).Start();
        Thread.Sleep(1000);
        stopLooping = true;
        Console.Write("Main() is waiting for Enter to be pressed...");
        Console.ReadLine();
        Console.WriteLine("Main() is returning.");
    }

    static void Loop()
    {
        /*
         * Stack frame setup (Native x86 code):
         *  00000000  push        ebp  
         *  00000001  mov         ebp,esp 
         *  00000003  push        edi  
         *  00000004  push        esi  
         */

        int i = 0;
        /*
         * Initialize 'i' to zero ('i' is in register edi)
         *  00000005  xor         edi,edi 
         */

        while (!stopLooping)
            /*
             * Load 'stopLooping' into eax, test and skip loop if != 0
             *  00000007  movzx       eax,byte ptr ds:[001E2FE0h] 
             *  0000000e  test        eax,eax 
             *  00000010  jne         00000017 
             */
        {
            i++;
            /*
             * Increment 'i'
             *  00000012  inc         edi  
             */

            /*
             * Test the cached value of 'stopped' still in
             * register eax and do it again if it's still
             * zero (false), which it is if we get here:
             *  00000013  test        eax,eax 
             *  00000015  je          00000012 
             */
        }

        Console.WriteLine("i={0}", i);
    }
}

复制成功。必须使用x86机器运行时,发布模式,无调试。在x64架构下,这并不是最优化的。 - TheSoftwareJedi

3

顺带一提:

  • 我曾经在MS C++编译器(非托管代码)中看到过这种编译优化。
  • 我不知道C#是否也会出现这种情况。
  • 调试时不会发生这种情况(编译器优化在调试期间自动禁用)。
  • 即使现在没有进行这种优化,你也在赌注着他们以后的JIT编译器版本中永远不会引入这种优化。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接