.NET中是否存在僵尸……?


385

我正在与队友讨论锁定.NET的问题。他是一个非常聪明的人,在低级和高级编程方面都有广泛的背景,但是他在低级编程方面的经验远远超过了我。无论如何,他认为应该在可能承受重负载的关键系统上避免.NET锁定,以免“僵尸线程”崩溃的可能性很小。我通常使用锁定,但我不知道“僵尸线程”是什么,所以我问。我从他的解释中得到的印象是,僵尸线程是已终止但仍保留了某些资源的线程。他举了一个关于僵尸线程如何破坏系统的示例,该示例是线程在锁定某个对象后开始执行某些过程,然后在可以释放锁之前终止。这种情况有可能使系统崩溃,因为最终尝试执行该方法将导致线程全部等待访问永远不会返回的对象,因为使用锁定对象的线程已死。

我想我明白了要点,但是如果我不在基地,请告诉我。这个概念对我来说很有意义。我并不完全相信这是.NET中可能发生的真实情况。我以前从未听说过“僵尸”,但我确实认识到在较低级别进行过深入研究的程序员往往对计算基础(例如线程)有更深的了解。我确实确实看到了锁定的价值,而且我看到许多世界一流的程序员都在利用锁定。我也没有能力自己评估这一点,因为我知道该lock(obj)语句实际上仅是以下方面的语法糖:

bool lockWasTaken = false;
var temp = obj;
try { Monitor.Enter(temp, ref lockWasTaken); { body } }
finally { if (lockWasTaken) Monitor.Exit(temp); }

并且因为Monitor.EnterMonitor.Exit被标记extern。可以想象.NET进行了某种处理,以保护线程免于暴露于可能造成这种影响的系统组件,但这纯粹是推测性的,并且可能基于我从未听说过“僵尸线程”的事实。之前。因此,我希望可以在这里获得一些反馈:

  1. 是否有比我在此解释的“僵尸线程”更清晰的定义?
  2. 僵尸线程可以出现在.NET上吗?(为什么/为什么不呢?)
  3. 如果适用,如何在.NET中强制创建僵尸线程?
  4. 如果适用,如何在不冒险使用.NET中僵尸线程的情况下利用锁定?

更新资料

两年多前,我问了这个问题。今天,这件事发生了:

对象处于僵尸状态。


8
您确定您的伴侣不会谈论僵局吗?
Andreas Niedermair 2013年

10
@AndreasNiedermair-我知道什么是僵局,这显然不是滥用该术语的问题。对话中提到了死锁,显然与“僵尸线程”不同。对我而言,主要区别在于,死锁具有双向无法解决的依赖性,而僵尸线程是单向的,并且需要终止的进程。如果您不同意并认为有更好的方法来查看这些内容,请解释一下
smartcaveman 2013年

19
我认为“僵尸”一词实际上来自UNIX背景,就像“僵尸进程”中一样,对吗?在UNIX中,“僵尸进程”有一个清晰的定义:它描述了一个终止的子进程,但是子进程的父进程仍然需要通过调用wait或来“释放”该子进程(及其资源)waitpid。子进程则称为“僵尸进程”。另请参阅howtogeek.com/119815
hogliux

9
如果程序的一部分崩溃,则程序处于未定义状态,那么这当然可能导致其余程序出现问题。如果您在单线程程序中不正确地处理异常,可能会发生同样的事情。问题不在于线程,问题在于您具有全局可变状态,并且没有正确处理意外的线程终止。您的“非常聪明”的同事完全不在此基础上。
Jim Mischel

9
“自从第一台计算机问世以来,机器中始终存在鬼影。随机的代码段已组合在一起以形成意外的协议……”
克里斯·拉普兰特

Answers:


242
  • 是否有比我在此解释的“僵尸线程”更清晰的定义?

在我看来,这是一个很好的解释-线程已终止(因此无法再释放任何资源),但是其资源(例如,句柄)仍然存在并且(可能)引起问题。

  • 僵尸线程可以出现在.NET上吗?(为什么/为什么不呢?)
  • 如果适用,如何在.NET中强制创建僵尸线程?

他们肯定会,看,我做了一个!

[DllImport("kernel32.dll")]
private static extern void ExitThread(uint dwExitCode);

static void Main(string[] args)
{
    new Thread(Target).Start();
    Console.ReadLine();
}

private static void Target()
{
    using (var file = File.Open("test.txt", FileMode.OpenOrCreate))
    {
        ExitThread(0);
    }
}

该程序启动一个线程Target,该线程打开一个文件,然后立即使用杀死自己ExitThread生成的僵尸线程将永远不会释放“ test.txt”文件的句柄,因此该文件将保持打开状态,直到程序终止(您可以使用进程浏览器或类似工具进行检查)。 直到GC.Collect被调用之前,“ test.txt”的句柄才会被释放-事实证明,创建一个泄漏句柄的僵尸线程比我想象的还要困难)

  • 如果适用,如何在不冒险使用.NET中僵尸线程的情况下利用锁定?

不要做我刚刚做的事!

只要您的代码可以正确地自行清理(如果使用非托管资源,则使用安全句柄或等效类),并且只要您不以奇怪而奇妙的方式来杀死线程(最安全的方法就是永远不要杀死线程-让它们正常终止自身,或者在必要时通过异常终止),要使事物具有类似于僵尸线程的唯一方法是,如果出现了严重错误(例如,CLR中出现了错误)。

实际上,创建一个僵尸线程实际上非常困难(我不得不P / Invoke进入一个函数,该函数在文档中实质上告诉您不要在C之外调用它)。例如,以下(糟糕的)代码实际上并未创建僵尸线程。

static void Main(string[] args)
{
    var thread = new Thread(Target);
    thread.Start();
    // Ugh, never call Abort...
    thread.Abort();
    Console.ReadLine();
}

private static void Target()
{
    // Ouch, open file which isn't closed...
    var file = File.Open("test.txt", FileMode.OpenOrCreate);
    while (true)
    {
        Thread.Sleep(1);
    }
    GC.KeepAlive(file);
}

尽管犯了一些非常糟糕的错误,但“ test.txt”的句柄仍在Abort调用后立即关闭(作为终结器的一部分,该过程的幕后file使用SafeFileHandle包装了文件句柄)

C.Evenhuis答案中的锁定示例可能是在线程以非古怪的方式终止时无法释放资源(在这种情况下为锁)的最简单方法,但是通过使用lock语句代替或将发布放在一起finally

也可以看看


3
我记得当我使用backgroundworker在Excel中保存内容时,我一直没有释放所有资源(因为我只是跳过调试等)。在任务管理器中,我随后看到了大约50个excel流程。我创建了zombieexcelprocesses吗?
Stefan 2013年

3
@Justin-+1-好的答案。我对你的ExitThread电话有点怀疑。显然,它是可行的,但它比实际情况更像是一个巧妙的把戏。我的目标之一就是学习不要做什么,这样我就不会意外地用.NET代码创建僵尸线程。我可能已经发现,从.NET代码中调用已知会导致该问题的C ++代码将产生所需的效果。您显然对这件事很了解。您是否知道其他任何情况(可能很怪异,但又怪异到永远不会无意发生),并且结果相同?
smartcaveman

3
因此答案是“不在c#4中”,对吗?如果您必须跳出CLR才能获得僵尸线程,这似乎不是.Net问题。
Gusdor

4
@Stefan我会说几乎绝对不会。我已经做了很多次您描述的事情。Excel进程不是僵尸,因为它们仍在运行,尽管不能通过普通UI立即访问。您应该能够通过调用GetObject来检索它们。Set excelInstance = GetObject(, "Excel.Application")
丹尼尔(Daniel)

7
“生成的僵尸线程将永远不会释放“ test.txt”文件的句柄,因此该文件将保持打开状态,直到程序终止为止”。小证明:`static void Main(string [] args){new Thread(GcCollect).Start(); new Thread(Target).Start(); Console.ReadLine(); }私有静态void Target(){使用(var file = File.Open(“ test.txt”,FileMode.OpenOrCreate)){ExitThread(0); }}私有静态void GcCollect(){while(true){Thread.Sleep(10000); GC.Collect(); }}
Sinix

46

我整理了一下答案,但下面留下了原始答案供参考

这是我第一次听说“僵尸”一词,因此我假设它的定义是:

在不释放所有资源的情况下终止的线程

因此,给定该定义,那么可以,您可以在.NET中执行此操作,就像使用其他语言(C / C ++,java)一样。

但是,我不认为这不是在.NET中编写线程化,关键任务代码的好理由。可能还有其他原因决定拒绝.NET,但仅因为您可能拥有僵尸线程而注销.NET对我来说没有任何意义。僵尸线程在C / C ++中是可能的(我什至认为使用C语言搞混起来要容易得多),并且许多关键的线程化应用程序都在C / C ++中(大宗交易,数据库等)。

结束语 如果您正在决定使用哪种语言,那么我建议您考虑全局:性能,团队技能,进度,与现有应用程序的集成等。当然,僵尸线程是您应该考虑的事情,但由于与.NET等其他语言相比,在.NET中实际犯此错误非常困难,因此我认为这种担忧将被上述其他事物所掩盖。祝好运!

如果您未编写正确的线程代码,则可能存在原始答案 僵尸。对于其他语言(例如C / C ++和Java)也是如此。但这不是不在.NET中编写线程代码的原因。

就像使用其他任何语言一样,在使用某些东西之前请先了解价格。它还有助于了解引擎盖下正在发生的事情,因此您可以预见任何潜在的问题。

无论您使用哪种语言,用于关键任务系统的可靠代码都不容易编写。但是我很肯定在.NET中并非不可能正确地编写代码。同样,AFAIK,.NET线程与C / C ++中的线程没有什么不同,除了某些.net特定的构造(如RWL和事件类的轻量级版本)外,它使用(或从)相同的系统调用构建。

我第一次听说过僵尸一词,但是根据您的描述,您的同事可能表示一个线程终止而没有释放所有资源。这可能会导致死锁,内存泄漏或其他不良影响。这显然不是可取的,但是由于这种可能性而单独选择.NET 可能不是一个好主意,因为在其他语言中也有可能。我什至认为,用C / C ++搞混比.NET容易(特别是在没有RAII的C语言中),但是很多关键应用程序都是用C / C ++编写的,对吗?因此,这实际上取决于您的个人情况。如果您想从应用程序中提取每盎司的速度,并希望尽可能接近裸机,那么.NET 可能并非最佳解决方案。如果预算紧张,并且要与Web服务/现有的.net库/等进行大量交互,那么.NET 可能是一个不错的选择。


(1)当我碰到死胡同的extern方法时,我不确定如何弄清楚到底发生了什么。如果您有建议,我很想听听。(2)我同意可以在.NET中进行。我想相信锁定是有可能的,但是我今天还没有找到令人满意的答案来证明这一点
smartcaveman 2013年

1
@smartcaveman如果您的意思lockinglock关键字,那么可能不是,因为它序列化了执行。为了最大化吞吐量,您必须根据代码的特性使用正确的结构。我想说的是,如果您可以使用pthreads / boost / thread pools / whatever用任何方式在c / c ++ / java /中编写可靠的代码,那么您也可以在C#中编写它。但是,如果您不能使用任何库以任何语言编写可靠的代码,那么我怀疑用C#编写会有所不同。
Jerahmeel 2013年

@smartcaveman指出了到底是什么,Google会帮忙堆放,如果您的问题太奇特而无法在网络上找到,则反射器非常方便。但是对于线程类,我发现MSDN文档非常有用。而且很多只是您在Cway中使用的相同系统调用的包装。
Jerahmeel 2013年

1
@smartcaveman一点也不,那不是我的意思。很抱歉,如果遇到这种情况。我想说的是,编写关键应用程序时,不要太急于注销.NET。当然,您可以做的事情可能比僵尸线程(我认为这些线程不会释放任何不受管理的资源,这在其他语言中可能完全发生:stackoverflow.com/questions/14268080/…)差很多,但是,但是再次,这并不意味着.NET不是可行的解决方案。
Jerahmeel 2013年

2
我认为.NET根本不是坏技术。实际上,这是我的主要开发框架。但是,由于这个原因,我认为重要的是要了解它容易受到的漏洞。基本上,我选择.NET,但是因为我喜欢它,而不是因为我不喜欢。(也不必担心,我+1了你)
smartcaveman

26

现在,我的大部分回答已被以下评论纠正。我不会删除答案,因为我需要信誉点,因为评论中的信息可能对读者有价值。

不朽的蓝指出,在.NET 2.0及更高版本中,finally块不受线程中止的影响。并且正如Andreas Niedermair所评论的那样,这可能不是实际的僵尸线程,但是以下示例显示了中止线程如何导致问题:

class Program
{
    static readonly object _lock = new object();

    static void Main(string[] args)
    {
        Thread thread = new Thread(new ThreadStart(Zombie));
        thread.Start();
        Thread.Sleep(500);
        thread.Abort();

        Monitor.Enter(_lock);
        Console.WriteLine("Main entered");
        Console.ReadKey();
    }

    static void Zombie()
    {
        Monitor.Enter(_lock);
        Console.WriteLine("Zombie entered");
        Thread.Sleep(1000);
        Monitor.Exit(_lock);
        Console.WriteLine("Zombie exited");
    }
}

但是,当使用lock() { }块时,以这种方式触发finallya仍将执行ThreadAbortException

事实证明,以下信息仅对.NET 1和.NET 1.1有效:

如果在该lock() { }块内发生另一个异常,并且ThreadAbortException恰好在该finally块将要运行时到达,则不会释放锁定。如您所述,该lock() { }块被编译为:

finally 
{
    if (lockWasTaken) 
        Monitor.Exit(temp); 
}

如果另一个线程Thread.Abort()在生成的finally块内调用,则可能不会释放该锁。


2
您正在谈论,lock()但是我看不到任何用法...所以-该如何连接?这是一个“错误”的使用Monitor.EnterMonitor.Exit(缺乏的用法tryfinally
安德烈亚斯Niedermair

4
我不会称其为僵尸线程-这只是对的错误使用,Monitor.EnterMonitor.Exit没有正确地使用tryfinally-无论如何,您的方案将锁定可能挂在其上的其他线程_lock,因此存在死锁情况-不一定是僵尸线程...另外,您没有释放锁定Main...但是,嘿...也许OP锁定死锁而不是僵尸线程了:)
Andreas Niedermair

1
@AndreasNiedermair僵尸线程的定义可能与我想的不一样。也许我们可以将其称为“已终止执行但尚未释放所有资源的线程”。试图删除并做我的作业,但保留与OP场景相似的答案。
C.Evenhuis

2
这里没有冒犯!实际上,您的回答让我开始思考:)由于OP明确地谈论了未释放的锁,我相信他的同伴谈论的是死锁-因为锁不是绑定到线程的实际资源(它的共享...... NONA) -因此可避免任何“僵尸”线程通过坚持最佳做法和使用lock或适当try/ finally-usage
安德烈亚斯Niedermair

1
@ C.Evenhuis-我不确定我的定义是否正确。我要提出这个问题的部分原因是为了解决这一问题。我认为这个概念是指巨资C / C ++
smartcaveman

24

这与Zombie线程无关,但是有效的C#一书中有一个关于实现IDisposable的部分(项目17),其中谈到了Zombie对象,我认为您可能会发现它很有趣。

我建议您阅读本书本身,但要点是,如果您有一个实现IDisposable的类,或者包含一个Desctructor的类,则您在这两个类中唯一要做的就是释放资源。如果您在此处执行其他操作,则有可能该对象将不会被垃圾回收,但也将无法以任何方式进行访问。

它给出了类似于以下示例:

internal class Zombie
{
    private static readonly List<Zombie> _undead = new List<Zombie>();

    ~Zombie()
    {
        _undead.Add(this);
    }
}

调用此对象上的析构函数时,对其自身的引用将放置在全局列表上,这意味着它在程序的生命周期内一直有效并保留在内存中,但不可访问。这可能意味着资源(尤其是非托管资源)可能没有被完全释放,这可能导致各种潜在问题。

下面是一个更完整的示例。在到达foreach循环时,“亡灵”列表中有150个对象,每个对象都包含一个图像,但是该图像已被GC处理,如果尝试使用它,则会出现异常。在此示例中,当尝试对图像执行任何操作时,无论是尝试保存图像,还是查看诸如高度和宽度之类的尺寸,我都会收到ArgumentException(参数无效):

class Program
{
    static void Main(string[] args)
    {
        for (var i = 0; i < 150; i++)
        {
            CreateImage();
        }

        GC.Collect();

        //Something to do while the GC runs
        FindPrimeNumber(1000000);

        foreach (var zombie in Zombie.Undead)
        {
            //object is still accessable, image isn't
            zombie.Image.Save(@"C:\temp\x.png");
        }

        Console.ReadLine();
    }

    //Borrowed from here
    //http://stackoverflow.com/a/13001749/969613
    public static long FindPrimeNumber(int n)
    {
        int count = 0;
        long a = 2;
        while (count < n)
        {
            long b = 2;
            int prime = 1;// to check if found a prime
            while (b * b <= a)
            {
                if (a % b == 0)
                {
                    prime = 0;
                    break;
                }
                b++;
            }
            if (prime > 0)
                count++;
            a++;
        }
        return (--a);
    }

    private static void CreateImage()
    {
        var zombie = new Zombie(new Bitmap(@"C:\temp\a.png"));
        zombie.Image.Save(@"C:\temp\b.png");
    }
}

internal class Zombie
{
    public static readonly List<Zombie> Undead = new List<Zombie>();

    public Zombie(Image image)
    {
        Image = image;
    }

    public Image Image { get; private set; }

    ~Zombie()
    {
        Undead.Add(this);
    }
}

同样,我知道您特别在询问有关僵尸线程的问题,但是问题标题是关于.net中的僵尸的,我想起了这一点,并以为其他人可能会觉得有趣!


1
这是有趣的。是_undead要静态的?
smartcaveman

1
所以我尝试了一下,从“被破坏的”对象上打印了一些。它将其视为普通对象。这样做有什么问题吗?
smartcaveman

1
我用一个例子更新了我的答案,希望可以更清楚地说明这个问题。
JMK 2013年

1
我不知道你为什么被拒绝。我发现它很有帮助。这是一个问题-您将获得哪种例外?我不希望它是NullReferenceException,因为我感觉到缺少的东西需要更多地绑定到机器上而不是应用程序上。这是正确的吗?
smartcaveman

4
当然,IDisposable问题不仅仅在于此。我对终结器(析构函数)感到担忧,但只是IDisposable不会使对象进入终结器队列并冒此僵尸场景的风险。此警告涉及终结器,它们可能调用Dispose方法。有一些示例在IDisposable没有终结符的类型中使用。情绪应该是资源清理,但这可能是不平凡的资源清理。RX有效地IDisposable用于清除订阅,并且可以调用其他下游资源。(顺便说一句,我也没投票过……)
James World

21

在高负载的关键系统上,编写无锁代码会更好,主要是因为性能有所提高。请看一下LMAX之类的东西,以及它如何利用“机械同情”来进行很好的讨论。担心僵尸线程吗?我认为这是一个边缘案例,只是需要解决的错误,而不是不使用的充分理由lock

听起来更像是您的朋友对我来说只是花哨的和炫耀他晦涩难懂的异国术语的知识!在我在Microsoft UK进行性能实验室的所有时间里,我从未在.NET中遇到过此问题的实例。


2
我认为不同的经历让我们对不同的错误产生了偏执。他承认这是在解释它的一个极端的边缘情况,但如果真的是一个边缘的情况下,而不是一个永不情况下,我想至少明白了一点好-感谢您的输入
smartcaveman

1
很公平。我只是不想让您过分担心这份lock声明!
James World

1
如果我对这个问题有更深入的了解,我就不用担心了。
smartcaveman

4
我之所以投票,是因为您试图摆脱一些线程不足的问题,而其中的太多了。Lock-FUD将需要做更多的工作来处理:)我只是想让开发人员采取无限制的自旋锁(出于性能考虑),以便他们可以将50K数据复制到宽队列中。
马丁·詹姆斯

3
是的,无论是一般的还是特殊的。编写正确的无锁代码与编程一样具有挑战性。以我在大多数情况下的经验来看,大颗粒的(相对)容易理解的胖lock块就很好了。我会避免为了性能优化而引入复杂性,直到您知道需要这样做为止。
James World

3

1.“僵尸线程”的定义是否比我在此解释的更清晰?

我确实同意“僵尸线程”的存在,这是一个术语,指的是线程剩下的资源,它们不会被释放,但不会完全消失,因此命名为“僵尸”,因此这个推荐的解释在金钱上是很正确的!

2.僵尸线程可以出现在.NET上吗?(为什么/为什么不呢?)

是的,它们可能发生。这是参考,Windows实际将其称为“ zombie”:MSDN对死进程/线程使用单词“ Zombie”

经常发生的是另一回事,这取决于您的编码技术和实践,就像您喜欢Thread Locking一样,并且已经进行了一段时间,我什至不必担心这种情况会发生在您身上。

是的,正如@KevinPanko在注释中正确提到的那样,“僵尸线程”确实来自Unix,这就是为什么它们在XCode-ObjectiveC中使用并称为“ NSZombie”并用于调试的原因。它的行为几乎是相同的...唯一的不同是,本应死亡的对象变成了用于调试的“ ZombieObject”,而不是“ Zombie Thread”,这可能是代码中的潜在问题。


1
但是,MSDN使用僵尸的方式与此问题的使用方式非常不同。
Ben Voigt

1
哦,是的,我同意,但是我要指出的是,即使MSDN在死时也将线程称为“僵尸线程”。并且它们实际上可能发生。
2013年

4
可以,但是它引用的是其他代码,它们仍然持有线程的句柄,而不是线程退出时持有资源的句柄。您的第一句话与问题的定义相符,这是怎么回事。
Ben Voigt

1
啊,我明白你的意思了。老实说,我什至没有注意到。我一直在强调他的定义是存在的,无论定义如何发生。请记住,存在该定义的原因是线程发生了什么,而不是如何完成的。
2013年

0

我可以很容易地使僵尸线程。

var zombies = new List<Thread>();
while(true)
{
    var th = new Thread(()=>{});
    th.Start();
    zombies.Add(th);
}

这会泄漏(的Join())线程句柄。就我们在​​托管环境中而言,这只是另一个内存泄漏。

现在,以实际上持有锁的方式杀死线程在后部很痛苦,但有可能。另一个人ExitThread()做这份工作。正如他发现的那样,文件句柄已被gc清除,但lock对象周围没有被清除。但是,为什么要这样做呢?

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.