ChatGPT解决这个技术问题 Extra ChatGPT

.NET 中是否存在僵尸?

我正在与一位队友讨论锁定在 .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 会执行某种处理来保护线程免受可能产生这种影响的系统组件的影响,但这纯粹是推测性的,可能只是基于我从未听说过“僵尸线程”这一事实前。所以,我希望我能在这里得到一些反馈:

是否有比我在这里解释的更清晰的“僵尸线程”定义?僵尸线程可以出现在 .NET 上吗? (为什么/为什么不?)如果适用,我如何强制在 .NET 中创建僵尸线程?如果适用,我如何利用锁定而不冒 .NET 中出现僵尸线程场景的风险?

更新

两年多前我问过这个问题。今天发生了这样的事情:

https://i.stack.imgur.com/qd6Wd.png

你确定你的同事不谈论僵局吗?
@AndreasNiedermair - 我知道什么是死锁,这显然不是滥用该术语的问题。对话中提到了死锁,并且明显不同于“僵尸线程”。对我来说,主要区别在于死锁具有双向不可解析的依赖关系,而僵尸线程是单向的,并且需要终止进程。如果您不同意并认为有更好的方式来看待这些事情,请解释
我认为“僵尸”这个词实际上来自 UNIX 背景,就像“僵尸进程”一样,对吧???在 UNIX 中有一个“僵尸进程”的明确定义:它描述了一个已经终止但子进程的父进程仍需要通过调用 waitwaitpid。然后子进程被称为“僵尸进程”。另请参阅howtogeek.com/119815
如果您的程序的一部分崩溃,使程序处于未定义状态,那么这当然会导致程序的其余部分出现问题。如果您在单线程程序中不正确地处理异常,也会发生同样的事情。问题不在于线程,问题在于您具有全局可变状态并且您没有正确处理意外的线程终止。你的“非常聪明”的同事完全不相信这个。
“从第一台计算机开始,机器中就一直存在幽灵。随机的代码段组合在一起形成了意想不到的协议……”

C
Community

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

对我来说似乎是一个很好的解释——一个线程已经终止(因此不能再释放任何资源),但其资源(例如句柄)仍然存在并且(可能)导致问题。

僵尸线程可以出现在 .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 中出现僵尸线程场景的风险?

不要做我刚才做的事!

只要您的代码正确地自行清理(如果使用非托管资源,请使用 Safe Handles 或等效类),并且只要您不竭尽全力以奇怪而奇妙的方式杀死线程(最安全的方式是只是为了永不杀死线程 - 让它们正常终止自己,或者在必要时通过异常终止),您将拥有类似于僵尸线程的东西的唯一方法是如果某些东西出了非常错误(例如在 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 answer 中的锁定示例可能是当线程以不奇怪的方式终止时无法释放资源(在这种情况下为锁)的最简单方法,但这很容易通过使用 lock 语句来解决,或将版本放在 finally 块中。

也可以看看

C# IL codegen 的微妙之处在于一个非常微妙的情况,即使使用 lock 关键字,异常也会阻止锁被释放(但仅在 .Net 3.5 和更早版本中)

锁和异常不能混用


我记得当我使用后台工作程序在 excel 中保存东西时,我并没有一直释放所有资源(因为我只是跳过了调试等)。在任务管理器中,我后来看到了大约 50 个 excel 进程。我创建了zombieexcelprocesses吗?
@贾斯汀-+1-很好的答案。不过,我对您的 ExitThread 调用有点怀疑。显然,它有效,但感觉更像是一个聪明的把戏,而不是一个现实的场景。我的目标之一是了解什么不应该做,这样我就不会意外地使用 .NET 代码创建僵尸线程。我可能已经知道从 .NET 代码调用已知会导致该问题的 C++ 代码会产生预期的效果。你显然对这些东西了解很多。您是否知道任何其他具有相同结果的情况(可能很奇怪,但还不够奇怪,不会无意发生)?
所以答案是'不在 c# 4' 中,对吧?如果必须跳出 CLR 才能获得僵尸线程,这似乎不是 .Net 问题。
@Stefan 我会说几乎肯定不会。我已经做了很多次你描述的事情。 Excel 进程不是僵尸进程,因为它们仍在运行,但无法通过普通 UI 立即访问。您应该能够通过调用 GetObject 来检索它们。 Set excelInstance = GetObject(, "Excel.Application")
“生成的僵尸线程永远不会释放“test.txt”文件的句柄,因此该文件将保持打开状态,直到程序终止”是不正确的。小证明:` static void Main(string[] args) { new Thread(GcCollect).Start();新线程(目标)。开始(); Console.ReadLine(); } private static void Target() { using (var file = File.Open("test.txt", FileMode.OpenOrCreate)) { ExitThread(0); } } 私有静态 void GcCollect() { while (true) { Thread.Sleep(10000); GC.Collect(); } }`
b
binki

我已经清理了我的答案,但将原始答案留在下面以供参考

这是我第一次听说僵尸这个词,所以我假设它的定义是:

已终止但未释放其所有资源的线程

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

但是,我不认为这是不在 .NET 中编写线程、任务关键型代码的充分理由。可能还有其他理由决定反对 .NET,但是仅仅因为你可以拥有僵尸线程而注销 .NET 对我来说没有意义。僵尸线程在 C/C++ 中是可能的(我什至认为在 C 中更容易搞砸),许多关键的线程应用程序都在 C/C++ 中(大容量交易、数据库等)。

结论 如果您正在决定要使用的语言,那么我建议您考虑全局:性能、团队技能、日程安排、与现有应用程序的集成等。当然,僵尸线程是您应该考虑的事情,但由于与其他语言(如 C)相比,在 .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 如果您所说的 lockinglock 关键字,那么可能不是因为它序列化执行。为了最大限度地提高吞吐量,您必须根据代码的特征使用正确的构造。我想说如果你可以使用 pthreads/boost/thread pools/whatever 在 c/c++/java/whatever 中编写可靠的代码,那么你也可以用 C# 编写它。但是,如果您不能使用任何库以任何语言编写可靠的代码,那么我怀疑用 C# 编写会有所不同。
@smartcaveman 至于找出引擎盖下的东西,谷歌会帮助你,如果你的问题太奇特而无法在网上找到,反射器非常方便。但是对于线程类,我发现 MSDN 文档非常有用。其中很多只是您在 C anway 中使用的相同系统调用的包装器。
@smartcaveman 根本不是,这不是我的意思。我很抱歉,如果它以这种方式出现。我想说的是,在编写关键应用程序时,您不应该太快注销 .NET。当然,您可以做一些比僵尸线程更糟糕的事情(我认为这只是没有释放任何非托管资源的线程,这完全可以在其他语言中发生:stackoverflow.com/questions/14268080/…),但同样,这并没有意味着 .NET 不是一个可行的解决方案。
我不认为.NET 是一项糟糕的技术。它实际上是我的主要开发框架。但是,正因为如此,我认为了解它容易受到的缺陷是很重要的。基本上,我选择了.NET,但因为我喜欢它,而不是因为我不喜欢它。 (别担心兄弟,我+1-ed你)
C
C.Evenhuis

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

Immortal Blue 指出,在 .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() { } 块时,finally 仍会在 ThreadAbortException 以这种方式被触发时执行。

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

如果在 lock() { } 块内发生其他异常,并且 ThreadAbortException 恰好在 finally 块即将运行时到达,则不会释放锁。正如您所提到的,lock() { } 块编译为:

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

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


你说的是 lock() 但我看不到这个的任何用法......所以 - 这是如何连接的?这是 Monitor.EnterMonitor.Exit 的“错误”用法(缺少 tryfinally 的用法)
我不会将其称为僵尸线程 - 这只是 Monitor.EnterMonitor.Exit 的错误使用,而没有正确使用 tryfinally - 无论如何,您的场景将锁定其他可能挂在 { 5},所以有一个死锁场景 - 不一定是僵尸线程......另外,你没有释放 Main 中的锁......但是,嘿......也许 OP 正在锁定死锁而不是僵尸 -线程:)
@AndreasNiedermair 也许僵尸线程的定义并不像我想的那样。或许我们可以称其为“已终止执行但尚未释放所有资源的线程”。很想删除并做我的作业,但保留与 OP 场景相似的答案。
这里没有冒犯!实际上,您的回答让我开始思考:) 由于 OP 明确谈论未释放的锁,我相信他的同事谈到了死锁 - 因为锁不是绑定到线程的真实资源(它是共享的...nöna) - 因此可以通过坚持最佳实践并使用 lock 或正确的 try/finally-usage 来避免任何“僵尸”线程
@C.Evenhuis - 我不确定我的定义是否准确。我问这个问题的部分原因是为了澄清这一点。我认为这个概念在 C/C++ 中被大量引用
J
JMK

这与 Zombie 线程无关,但 Effective C# 一书中有一个关于实现 IDisposable 的部分(第 17 项),其中讨论了我认为您可能会感兴趣的 Zombie 对象。

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

它给出了一个类似于下面的例子:

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

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

当调用此对象的析构函数时,对自身的引用将放置在全局列表中,这意味着它在程序的整个生命周期内都保持活动状态并在内存中,但不可访问。这可能意味着资源(尤其是非托管资源)可能无法完全释放,这可能会导致各种潜在问题。

下面是一个更完整的示例。到达 foreach 循环时,Undead 列表中有 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 中的僵尸,我想起了这一点,并认为其他人可能会觉得它很有趣!


这是有趣的。 _undead 是静态的吗?
所以我试了一下,打印了一些“被破坏”的对象。它把它当作一个普通的对象。这样做有什么问题吗?
我用一个例子更新了我的答案,希望能更清楚地说明这个问题。
我不知道你为什么被否决。我发现它很有帮助。这里有一个问题——你会得到什么样的例外?我不希望它是 NullReferenceException,因为我觉得缺少的东西需要更多地与机器而不是应用程序相关联。这是正确的吗?
当然,问题不在于 IDisposable。我对终结器(析构函数)感到担忧,但仅仅拥有 IDisposable 不会使对象进入终结器队列并冒这种僵尸场景的风险。此警告涉及终结器,它们可能会调用 Dispose 方法。有些示例在没有终结器的类型中使用了 IDisposable。情绪应该是资源清理,但这可能是不平凡的资源清理。 RX 有效地使用 IDisposable 来清理订阅,并且可以调用其他下游资源。 (顺便说一句,我也没有投反对票……)
J
James World

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

听起来你的朋友更像是在向我炫耀他对晦涩的异国术语的了解!在我在 Microsoft UK 运行性能实验室的所有时间里,我从未在 .NET 中遇到过这个问题的实例。


我认为不同的经历让我们对不同的错误感到偏执。他在解释时承认这是一个极端的情况,但如果它真的是一个极端的情况而不是从来没有的情况,我想至少更好地理解它 - 感谢您的意见
很公平。我只是不希望您过度担心 lock 声明!
如果我对这个问题有更深入的了解,我就不会那么担心了。
我投了赞成票,因为您正试图摆脱一些线程 FUD,其中太多了。 Lock-FUD 将需要更多的工作来处理:)当开发人员采用无界自旋锁(为了性能)时,我只是畏缩,这样他们就可以将 50K 的数据复制到一个宽队列中。
是的,无论是一般的还是特殊的。编写正确的无锁代码与编程一样具有挑战性。以我对绝大多数情况的经验,粗大的粗粒度(相对)易于理解的 lock 块就可以了。在你知道你需要之前,我会避免为了性能优化而引入复杂性。
W
Wai Ha Lee

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

我确实同意“僵尸线程”的存在,这是一个术语,指的是留下资源的线程会发生什么,他们不会放手但没有完全死亡,因此得名“僵尸”,所以你的这个转介的解释很对钱!

2. .NET 上会不会出现僵尸线程? (为什么/为什么不?)

是的,它们可能会发生。这是一个参考,实际上被 Windows 称为“僵尸”:MSDN uses the Word "Zombie" for Dead processes/threads

经常发生它是另一回事,并且取决于您的编码技术和实践,至于您喜欢线程锁定并且已经做了一段时间,我什至不会担心这种情况会发生在您身上。

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


但是 MSDN 使用僵尸的方式与这个问题使用它的方式非常不同。
哦,是的,我同意,但我要指出的是,即使 MSDN 在死时也将线程称为僵尸线程。并且它们实际上可以发生。
当然,但它指的是其他一些仍然持有线程句柄的代码,而不是在退出时持有资源句柄的线程。您的第一句话同意问题中的定义,这就是问题所在。
啊,我明白你的意思了。老实说,我什至没有注意到这一点。我专注于他的观点,即定义存在,不管它是如何发生的。请记住,定义的存在是因为线程发生了什么,而不是它是如何完成的。
J
Joshua

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

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

这会泄漏线程句柄(对于 Join())。就我们在托管世界中所关心的而言,这只是另一个内存泄漏。

现在,以一种实际上持有锁的方式杀死一个线程是一件痛苦的事,但有可能。另一个人的 ExitThread() 完成了这项工作。正如他发现的那样,文件句柄已被 gc 清理,但对象周围的 lock 不会。但你为什么要这样做?