长时间运行的进程被暂停了。

11

我有一个运行在 Windows Server GoDaddy VPS 上,使用 Visual Studio 2010 IDE 中的 debug mode (F5) 运行 .NET 2.0 控制台应用程序。

这个应用程序会周期性地冻结(就像垃圾回收器暂时挂起了执行),但极少数情况下它永远不会恢复执行!

我已经在排除故障了几个月,但想法越来越少。

  • 该应用程序以正常优先级尽可能快地运行(使用100%的 CPU 使用率),并且是多线程的。
  • 当应用程序冻结时,我可以通过暂停/继续进程(因为它在调试器中运行)来解冻它。
  • 当我暂停被冻结的进程时,上次执行的位置似乎并不重要。
  • 冻结时,CPU 使用率仍为 100%。
  • 解冻后,它可以完美地运行直到下一次冻结。
  • 服务器可能在两次冻结之间运行70天,也可能只能维持24小时。
  • 内存使用保持相对稳定;没有任何内存泄漏的证据。

请问有人有诊断发生了什么的提示吗?


9
不使用调试器运行程序,看看是否仍会失败,这样就可以轻松找出问题所在。 - Krumelur
2
@Krumelur 等待可能长达70天的下一次冻结并不像是一个轻松的发现问题的方式。同时,没有调试器附加来诊断可能发生的任何其他问题。 - Mr. Smith
1
很难根据如此抽象的问题描述提出建议。我建议您按照以下步骤操作:
  1. 在没有调试器的情况下运行程序。您可以在任何时候附加调试器来解决问题。
  2. 当进程冻结时,获取进程转储。使用Visual Studio或WinDBG + sos扩展程序来调查转储。或者将转储放在某个地方,并在此处发布链接。还需要使用构建生成的PDB文件。
- Sergey Zyuzin
看起来是时候获取完整版的VS2010了,这样你才能正确地进行调试... - hyde
你想在线程上执行什么操作?你在线程上有循环吗?你是否在线程上访问数据库? - Amit Bagga
显示剩余12条评论
3个回答

16

它也支持多线程

这是问题的关键部分。您正在描述一个多线程程序可能出现的典型问题。它正遭受死锁,这是线程的典型问题之一。

从信息中可以进一步缩小范围,显然您的进程并没有完全冻结,因为它仍然消耗着100%的CPU。您可能在代码中有一个热等待循环,在该循环中,一个线程会一直等待另一个线程发出信号。这可能会引起一种特别恶劣的死锁,即活锁。 活锁对时间非常敏感,代码运行顺序的微小变化可能使其陷入活锁状态,并再次跳出来。

由于尝试调试会使此状况消失,因此找到活锁错误非常困难。例如,附加调试器或打断点都足以改变线程的时间表并将其从条件中移出。或者在代码中添加日志记录语句,这是调试线程问题的常用策略。但这会由于日志记录开销而改变时间表,进而可能使活锁完全消失。

这很麻烦,而且很难从像SO这样的网站上获得对此类问题的帮助,因为它极其依赖于代码。通常需要全面审查代码才能找到原因。并且往往需要进行彻底的重写。祝您好运。


死锁似乎非常可能,但我使用的唯一同步工具是lock语句(并且从不嵌套锁)。主线程将在访问资源之前从工作线程中“锁定”资源,而工作线程将在访问资源之前仅锁定其自己的资源。没有跨工作线程锁定;主线程是唯一访问工作线程的线程,并且每次只访问一个线程。假设这是真的,调试器将如何解除这两个线程之间的死锁?时间不重要,其他线程也不重要。 - Mr. Smith
2
嗯,那并不能帮助我帮助你。我需要看到代码,但现在没有任何东西可以查看。但很明显,你谈论它的方式显示出你的方法存在根本性缺陷。你永远不应该“锁定资源”。在锁定语句中使用的引用应始终是专用的简单对象类型引用,其唯一工作是跟踪代码状态。它永远不应该是“资源”,因为这会导致死锁。你无法锁定数据,只能阻塞代码。 - Hans Passant
@史密斯先生:调试器如何解除这两个线程之间的死锁?-> 仅举一个例子:调试器停在一个进程中,给另一个进程完成的时间。当您进入并按下F5时,另一个进程已经完成,死锁消失,一切恢复正常运行。 - efkah
在编程中,锁定(lock)私有对象实例是最佳实践,但不这样做肯定不是根本性的缺陷;如果您不遵循此最佳实践,则需要非常注意使用 lock(this)lock(typeof(MyType)) 和(唯一一个曾经欺骗我的)lock(some_instance_of_a_string) 的危险。 - Mr. Smith
此外,具有弱标识的对象(如Thread、String、ParameterInfo等)。 - Mr. Smith
6
做正确的事情是不需要付出任何代价的。但是,从死锁的角度思考这个问题并不是很有意义。死锁程序不会占用100%的核心。强烈的迹象表明是活锁,集中精力去找出为什么你的程序在全速运行但却没有进展的解释,这样你就更可能诊断出问题所在。 - Hans Passant

2
应用程序是否有“死锁恢复/预防”代码?也就是说,在加锁之后,进行超时操作,然后再次尝试,可能会在睡眠后再次尝试?
应用程序是否在任何地方检查错误代码(返回值或异常),并在出现错误时反复重试?
请注意,这种循环也可以通过事件循环发生,其中您的代码仅位于某个事件处理程序中。它不一定是您自己代码中的实际循环。虽然这可能不是问题的原因,但如果应用程序被冻结,表示事件循环被阻塞。
如果您有类似上述的情况,您可以尝试通过使超时和睡眠成为随机间隔,并在可能产生死锁/活锁的情况下添加短暂的随机持续时间睡眠来缓解该问题。如果这样的循环对性能敏感,请添加计数器,并在一些失败的重试之后开始使用随机,可能增加的间隔进行睡眠。并确保您添加的任何睡眠都不会在某些东西被锁定时睡眠。
如果这种情况经常发生,您还可以使用此方法对您的代码进行二分并确定哪些循环(因为100%的CPU使用率意味着某些非常繁忙的循环正在旋转)负责。但是从问题的稀有性来看,我想您会很高兴在实践中遇到这个问题时它能够自动消失。

没有死锁恢复/预防代码。有一些地方我用了 catch{},但我不重试;我使用 catch{} 是因为这些地方可能因为普通的(且被充分理解的)原因而失败(例如,套接字关闭)。在使用 catch{} 的情况下,我不尝试重新发送,只是优雅地失败。如果问题消失了,我会很高兴,但即使无法解决,我也会接受一个解释导致问题出现的答案。 - Mr. Smith

0

首先,开始使用 .NET 的服务器 GC:http://msdn.microsoft.com/en-us/library/ms229357.aspx。这样可能会让您的应用程序非阻塞。

其次,如果您可以在 VM 上进行操作,请检查更新。这似乎总是显而易见的,但我见过很多情况,一个简单的 Windows 更新就可以解决奇怪的问题。

第三点,我想提出一个关于对象生命周期的观点,这可能是其中一个问题。这个故事相当长,所以请耐心听我说。

对象的生命周期基本上是构建-垃圾回收-终结。所有三个过程都在单独的线程中运行。GC 将数据传递给终结线程,后者有一个队列来调用“析构函数”。

那么,如果您有一个做一些奇怪事情的终结器,比如:

public class FinalizerObject
{
    public FinalizerObject(int n)
    {
        Console.WriteLine("Constructed {0}", n);
        this.n = n;
    }

    private int n;

    ~FinalizerObject()
    {
        while (true) { Console.WriteLine("Finalizing {0}...", n); System.Threading.Thread.Sleep(1000); }
    }
}

由于终结器在处理队列的单独线程中运行,因此拥有一个执行愚蠢操作的单个终结器对您的应用程序是一个严重的问题。您可以通过使用上述类2次来查看此问题:

    static void Main(string[] args)
    {
        SomeMethod();
        GC.Collect(GC.MaxGeneration);
        GC.WaitForFullGCComplete();
        Console.WriteLine("All done.");
        Console.ReadLine();
    }

    static void SomeMethod()
    {
        var obj2 = new FinalizerObject(1);
        var obj3 = new FinalizerObject(2);
    }

请注意,如果您删除Thread.Sleep,您将遇到一个小的内存泄漏,如果不删除,则会出现100%的CPU进程 - 即使您的主线程仍在响应。因为它们是不同的线程,所以从这里开始很容易阻止整个进程 - 例如使用锁定:
    static void Main(string[] args)
    {
        SomeMethod();
        GC.Collect(GC.MaxGeneration);
        GC.WaitForFullGCComplete();
        Thread.Sleep(1000);
        lock (lockObject)
        {
            Console.WriteLine("All done.");
        }
        Console.ReadLine();
    }

    static object lockObject = new Program();

    static void SomeMethod()
    {
        var obj2 = new FinalizerObject(1, lockObject);
        var obj3 = new FinalizerObject(2, lockObject);
    }

    [...]

    ~FinalizerObject()
    {
        lock (lockObject) { while (true) { Console.WriteLine("Finalizing {0}...", n); System.Threading.Thread.Sleep(1000); } }
    }

所以我可以看到你在想“你是认真的吗?”;事实上,你可能正在做这样的事情,甚至没有意识到。这就是“yield”的作用:

从“yield”返回的IEnumerable实际上是IDisposable,并且作为实现IDisposable模式。将您的“yield”实现与锁定组合,忘记使用'MoveNext'枚举调用IDisposable等,您将得到一些相当令人讨厌的行为,反映了上述情况。特别是因为终结器是由单独的线程从终结队列中调用的!将其与无限循环或线程不安全的代码结合使用,您将获得一些相当令人讨厌的意外行为,在异常情况下触发(当内存耗尽或GC认为它应该执行某些操作时)。

换句话说:我会检查您的可处置物和终结器,并对它们非常挑剔。检查“yield”是否具有隐式终结器,并确保您从同一线程调用IDisposable。以下是一些需要注意的事项的示例:

    try
    {
        for (int i = 0; i < 10; ++i)
        {
            yield return "foo";
        }
    }
    finally
    {
        // Called by IDisposable
    }

    lock (myLock) // 'lock' and 'using' also trigger IDisposable
    {
        yield return "foo";
    }

顺便说一句:我曾经遇到过一个真实的案例,其中“调试器”解冻您描述的问题实际上是问题(3)与非托管代码(在我的情况下是Socket IO)相结合。在这种情况下,Thread.Abort不起作用,但是您的调试器有时会做一些奇怪的事情。不幸的是,我无法为您提供最小测试用例;很难再现。 - atlaste
我不知道我的环境默认使用哪个GC(但只是因为我并不在意)。话虽如此,MSDN建议不要在单核机器上使用服务器GC。此外,它指出,如果在.NET 4.0或更早版本中使用服务器GC,则无法使用并发GC(这将减少阻塞所有线程的需要)。我倾向于每两周在机器上运行Windows更新,所以它没有落后于更新。最后,我的代码不包含任何终结器。 - Mr. Smith
@Mr.Smith 嗯,这让事情变得复杂了。说实话,如果您没有任何隐式终结器,我会感到惊讶,因为它们几乎在.NET框架的所有地方都被使用...尽管如此,您的反应确实使事情变得复杂了。我建议您尝试使用一个名为“Managed Stack Explorer”的工具,在冻结时转储所有线程的所有堆栈跟踪,以便更深入地了解发生了什么。另外,您可以尝试在Mono而不是MS .NET中运行它,以查看是否是运行时错误。 - atlaste
Managed Stack Explorer看起来非常方便;我已经升级到VS2012 Express(它有一个适当的多线程调试器),所以下一次冻结时,我应该能够查看所有线程的状态。至于使用Mono的建议,这个应用程序需要Microsoft SQL Server。虽然我编写的.NET Socket代码在Mono框架上运行了多年(在项目的其他部分中),并且很稳定。 - Mr. Smith

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