在发布模式下程序卡住了,但在调试模式下正常运行。

112
下面的代码在调试模式下按预期工作,完成后等待500毫秒,但在发布模式下无限期挂起:
public static void Main(string[] args)
{            
   bool isComplete = false;

   var t = new Thread(() =>
   {
       int i = 0;
               
        while (!isComplete) i += 0;
   });
 
   t.Start();
    
   Thread.Sleep(500);
   isComplete = true;
   t.Join();
   Console.WriteLine("complete!");
}

我看到这个问题,想知道在调试模式和发布模式下出现这种行为的原因。


25
行为上究竟有何不同? - Mong Zhu
4
如果这是Java的话,我会认为编译器没有看到对“compile”变量的更新。在变量声明中添加“volatile”关键字可以解决这个问题(并将其变成一个静态字段)。 - Sebastian
25
啊,海森堡虫 - David Richerby
4
请注意:这就是为什么多线程开发需要使用互斥锁和原子操作。当你开始进行多线程开发时,你需要考虑一组非常显然的附加内存问题。线程同步工具,如互斥锁,可以解决这些问题。 - Cort Ammon
5
@DavidSchwartz:当然,这是被允许的。编译器、运行时和CPU可以使代码的结果与预期不同。特别是,在C#中,不允许对bool访问进行非原子化处理,但允许将非易失性读取向时间轴后移动。相比之下,double变量没有原子性限制;在两个不同的线程上未进行同步的情况下对double进行读写是被允许分裂的。 - Eric Lippert
显示剩余6条评论
4个回答

151

我猜测优化器被isComplete变量缺少'volatile'关键字所迷惑了。

当然,你不能添加它,因为它是一个局部变量。而且,既然它是一个局部变量,它根本不需要,因为局部变量保存在堆栈上,它们自然总是“新鲜的”。

然而,编译后,它就不再是一个局部变量了。由于它在匿名委托中被访问,代码被拆分,被转换为一个辅助类和成员字段,类似于:

public static void Main(string[] args)
{
    TheHelper hlp = new TheHelper();

    var t = new Thread(hlp.Body);

    t.Start();

    Thread.Sleep(500);
    hlp.isComplete = true;
    t.Join();
    Console.WriteLine("complete!");
}

private class TheHelper
{
    public bool isComplete = false;

    public void Body()
    {
        int i = 0;

        while (!isComplete) i += 0;
    }
}

我现在可以想象,在多线程环境下,当处理TheHelper类时,JIT编译器/优化器实际上可以在Body()方法开始时将值false缓存在某个寄存器或堆栈帧中,并且直到该方法结束前都不会刷新它。这是因为无法保证线程和方法在执行“=true”之前不会结束,所以如果没有保证,为什么不将其缓存并获得每次迭代读取堆对象的性能提升呢?
这正是关键字volatile存在的原因。
为了使此辅助类在多线程环境中更好地工作,它应该具有以下内容:
    public volatile bool isComplete = false;

当然,由于它是自动生成的代码,所以您无法添加它。更好的方法是在读写isCompleted时添加一些lock(),或者使用一些其他现成的同步或线程/任务工具,而不是试图裸机运行它(因为它不会是裸机,因为它是带有GC、JIT和(..)的CLR上的C#)。
调试模式与发布模式之间的差异可能是因为在调试模式下,许多优化被排除在外,因此您可以调试屏幕上看到的代码。因此,while (!isComplete)未经过优化,因此可以在那里设置断点,而且isComplete在方法启动时不会被积极缓存到寄存器或堆栈中,并且在每个循环迭代中从堆上的对象中读取。
顺便说一句,这只是我的猜测。我甚至没有尝试编译它。
顺便说一句,这似乎不是一个bug;它更像是一个非常晦涩的副作用。此外,如果我对此正确的话,那么它可能是语言的缺陷-C#应允许在闭包中捕获并提升为成员字段的局部变量上放置“volatile”关键字。
1)请参见下面Eric Lippert关于volatile和/或这篇非常有趣的文章,其中显示了确保依赖于volatile的代码是安全的..嗯,好的..uh,让我们说OK所涉及的复杂程度。

7
@quetzalcoatl:我不认为这个功能会很快被添加。这是一种您希望避免,而不是更容易实现的编码方式。此外,使所有东西都易失并不能解决每个问题。这里有一个例子,在这个程序中所有变量都是易失的,但程序仍然是错误的;您能找出错误吗?http://blog.coverity.com/2014/03/26/reordering-optimizations/ - Eric Lippert
3
明白了。我放弃尝试理解多线程优化......它太复杂了,令人发狂。 - InBetween
10
再次思考如何优化。你有一个被增加但从未被读取的变量。一个从未被读取的变量可以完全删除。 - Eric Lippert
4
@EricLippert,现在我恍然大悟了。这个帖子非常有启发性,非常感谢你,真的。 - Pikoh
2
@Mehrdad:责任和功劳应该分配给很多人,从芯片设计师到操作系统设计师,再到语言设计师和用户。所有这些人都对性能、易用性和可靠性有期望;不幸的是,这些东西经常发生冲突。我个人是否希望内存模型更强大,即使以性能为代价?是的。我是否希望在多线程工具箱中使用的跳转工具比锁具有更好的属性?绝对是。但我们通过在许多相互冲突的目标之间进行妥协来达成了这种情况。 - Eric Lippert
显示剩余12条评论

83

quetzalcoatl的回答是正确的。以下是更多解释:

C#编译器和CLR Jitter有允许做很多优化,这些优化基于当前线程是唯一运行的线程的假设。如果这些优化在当前线程不是唯一运行的线程的情况下导致程序不正确,那么这将成为您的问题。您必须编写多线程程序,并告知编译器和Jitter您正在进行的多线程操作。

在这种特定情况下,Jitter允许 - 但不是必须 - 观察变量在循环体中未改变并因此得出结论 - 由于假设这是唯一运行的线程 - 变量永远不会更改。如果它从来不改变,则需要仅检查一次真实性变量,而不是每次通过循环。这实际上就是发生了什么。

如何解决这个问题?不要编写多线程程序。即使对于专家来说,多线程也非常难以正确实现。如果必须这样做,那么使用最高级别的机制来实现目标。本例的解决方案不是使变量易失。本例的解决方案是编写可取消任务并使用任务并行库取消机制。让TPL担心正确处理线程逻辑和跨线程发送取消请求。


1
评论不适合进行长时间的讨论;此对话已被移至聊天室 - Madara's Ghost

14

我附加到正在运行的进程上,然后发现(如果我没有犯错的话,因为我对此不太熟练),Thread 方法被翻译成了这个:

debug051:02DE04EB loc_2DE04EB:                            
debug051:02DE04EB test    eax, eax
debug051:02DE04ED jz      short loc_2DE04EB
debug051:02DE04EF pop     ebp
debug051:02DE04F0 retn

eax(其中包含isComplete的值)只在第一次加载时被读取,之后不再更新。


8

虽然不是答案,但以下内容可以更好地解释问题:

问题似乎出现在将i声明在lambda函数体内,并且仅在赋值表达式中读取的情况下。否则,在发布模式下,代码正常工作:

  1. i declared outside the lambda body:

    int i = 0; // Declared outside the lambda body
    
    var t = new Thread(() =>
    {
        while (!isComplete) { i += 0; }
    }); // Completes in release mode
    
  2. i is not read in the assignment expression:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { i = 0; }
    }); // Completes in release mode
    
  3. i is also read somewhere else:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { Console.WriteLine(i); i += 0; }
    }); // Completes in release mode
    

我的猜测是编译器或JIT优化与i有关,这可能会对事情造成混乱。比我聪明的人可能能够更详细地解释这个问题。

尽管如此,我不会过于担心它,因为我无法看到类似代码实际上会有任何作用。


1
看我的答案,我相信这与“volatile”关键字有关,它不能添加到局部变量(实际上稍后会升级为闭包中的成员字段)。 - quetzalcoatl

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