当线程池的可用工作线程总数达到零时,线程池从哪里获取新线程?

5

只是出于好玩,我编写了这段代码来模拟死锁。然后,我耐心地坐下来观察它的运行,直到线程池中可用工作线程的总数降为零。我很好奇会发生什么。它会抛出异常吗?

using System;
using System.Diagnostics;
using System.Threading;

namespace Deadlock
{
    class Program
    {
        private static readonly object lockA = new object();
        private static readonly object lockB = new object();

        static void Main(string[] args)
        {
            int worker, io;

            ThreadPool.GetAvailableThreads(out worker, out io);

            Console.WriteLine($"Total number of thread pool threads: {worker}, {io}");
            Console.WriteLine($"Total threads in my process: {Process.GetCurrentProcess().Threads.Count}");
            Console.ReadKey();

            try
            {
                for (int i = 0; i < 1000000; i++)
                {
                    AutoResetEvent auto1 = new AutoResetEvent(false);
                    AutoResetEvent auto2 = new AutoResetEvent(false);

                    ThreadPool.QueueUserWorkItem(ThreadProc1, auto1);
                    ThreadPool.QueueUserWorkItem(ThreadProc2, auto2);

                    var allCompleted = WaitHandle.WaitAll(new[] { auto1, auto2 }, 20);

                    ThreadPool.GetAvailableThreads(out worker, out io);
                    var total = Process.GetCurrentProcess().Threads.Count;

                    if (allCompleted)
                    {    
                        Console.WriteLine($"All threads done: (Iteration #{i + 1}). Total: {total}, Available: {worker}, {io}\n");
                    }
                    else
                    {
                        Console.WriteLine($"Timed out: (Iteration #{i + 1}). Total: {total}, Available: {worker}, {io}\n");
                    }
                }

                Console.WriteLine("Press any key to exit...");
            }
            catch(Exception ex)
            {
                Console.WriteLine("An exception occurred.");
                Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
                Console.WriteLine("The program will now exit. Press any key to terminate the program...");
            }

            Console.ReadKey();
        }

        static void ThreadProc1(object state)
        {
            lock(lockA)
            {
                Console.WriteLine("ThreadProc1 entered lockA. Going to acquire lockB");

                lock(lockB)
                {
                    Console.WriteLine("ThreadProc1 acquired both locks: lockA and lockB.");

                    //Do stuff
                    Console.WriteLine("ThreadProc1 running...");
                }
            }

            if (state != null)
            {
                ((AutoResetEvent)state).Set();
            }
        }

        static void ThreadProc2(object state)
        {
            lock(lockB)
            {
                Console.WriteLine("ThreadProc2 entered lockB. Going to acquire lockA.");

                lock(lockA)
                {
                    Console.WriteLine("ThreadProc2 acquired both locks: lockA and lockB.");

                    // Do stuff
                    Console.WriteLine("ThreadProc2 running...");
                }
            }

            if (state != null)
            {
                ((AutoResetEvent)state).Set();
            }
        }
    }
}

同时,我也一直运行着Windows任务管理器的性能选项卡,并观察操作系统线程的总数随着我的程序占用更多线程而增加。

以下是我的观察结果:

  1. 由于每次.NET线程池创建一个新线程,操作系统并没有创建更多的线程。实际上,对于我的for循环运行的每四或五个迭代,操作系统的线程计数会增加一到两个。这很有趣,但这不是我的问题。它证明了已经被证实的内容

  2. 更有趣的是,我观察到,我的for循环的每次迭代中线程数量并没有减少2个。我期望应该会减少2个,因为我所有死锁的线程都无法返回,因为它们正在互相等待。

  3. 我还观察到,当线程池中可用的工作线程总数降至零时,程序仍然继续运行我的for循环的更多迭代。这让我好奇,如果线程池中已经没有了线程,并且没有任何线程返回,那么新线程从哪里来?

enter image description here

enter image description here

因此,澄清一下,我的两个问题,也许是相关的,因为一个答案可能是它们的解释:

  1. 当我的for循环运行了一个迭代时,对于其中的某些迭代,没有创建线程池线程。为什么?线程池从哪里获取线程来运行这些迭代?

  2. 当线程池用尽了可用的工作线程总数并仍然保持运行我的for循环时,线程池从哪里获取线程?


1
也许 Monitor.Enter 逻辑允许 锁定 的线程被重用。为什么不呢?反正线程正在等待,让我们在其中运行另一个任务等等。 - Sinatr
这是一个不错但有点奇怪的理论,曾经在我的脑海中闪过。 :-) - Water Cooler v2
3个回答

8
    ThreadPool.GetAvailableThreads(out worker, out io);

这并不是一个很好的统计数据来说明线程池的工作原理。主要问题是,在所有最近的.NET版本中,它都是一个荒谬地大的数字。在我的双核笔记本电脑上,32位模式下从1020开始,64位模式下为32767。远远超出了这样一个贫弱的CPU合理处理的范围。这个数字随着时间的推移显著膨胀,它从.NET 2.0开始就是核心数的50倍。现在它是根据机器能力动态计算的,是CLR主机的工作。它使用的杯子已经超过半满。
线程池管理器的主要工作是保持线程高效。最佳状态是将正在执行的线程数量限制为处理器核心数量。运行更多会降低性能,操作系统必须在线程之间进行上下文切换,增加开销。
然而,这种理想情况并非总是能够实现,程序员编写的实际tp线程并不总是表现良好。实际上,它们花费的时间太长,或者在I/O或锁上阻塞的时间太长,而不是执行代码。当然,您的示例是一种相当极端的阻塞情况。
线程池管理器并不知道一个tp线程为什么执行时间太长。它只能看到执行时间过长。深入了解线程为什么执行时间过长是不切实际的,需要调试器和程序员大脑中训练有素的大规模并行神经网络。
每秒钟两次,线程池管理器重新评估工作负载,并在没有活动线程完成时允许额外的tp线程启动。即使这超出了最佳状态。理论上,这可能会完成更多的工作,因为活动线程阻塞太多,不能有效地利用可用的核心。解决一些死锁场景也很重要,尽管你永远不希望需要那样做。它就像任何其他常规线程一样,底层操作系统调用是CreateThread()。
所以你看到的是,可用线程数量每秒钟下降一次。独立于你的代码,这是基于时间的。实际上,在管理器中实现了一个反馈环路,试图动态计算额外线程的最佳数量。如果所有线程都被阻塞,你还没有达到那个状态。
这并不会一直持续下去,你最终会达到由默认的SetMaxThreads()设置的高上限。没有异常,假设你没有先遇到OutOfMemoryException,这在现实生活中很常见,它只是停止添加更多线程。你仍然在向线程池添加执行请求,涵盖了第3点,只是它们从未真正开始。当请求数量变得太大时,最终你会耗尽内存。你需要等待很长时间,需要一段时间才能填满一千兆字节。

非常感谢。我记得Jeff Richter在Channel 9的这个视频中演示了你所说的关于内存不足异常的情况,该视频是一段时间以前发布的。https://channel9.msdn.com/Shows/AppFabric-tv/AppFabrictv-Threading-with-Jeff-Richter非常感谢您的解释。我发现您的答案非常有帮助。 - Water Cooler v2
“最佳状态是将执行线程数量限制在处理器核心数量之内。” -- 更一般地说,非等待线程的平均总数应该是核心数,计算来自所有进程的所有线程。“然而,这种理想并不总是能够实现,程序员编写的实际线程并不总是表现良好。” -- 有时你别无选择,例如在ASP.NET中,每个请求都会得到一个线程池线程,如果控制器是同步的,除了最简单的请求(或缓存命中)外,大多数情况下它都会在某个时刻阻塞。 - acelent
如果您确实必须与运行在同一台计算机上的其他进程作斗争,或者无法控制何时使用线程,则总有一个简单的解决方案:将它们运行在另一台计算机上。对于Web服务器来说,这是肯定的,因为ASP.NET可以很好地跨服务器集群进行扩展。 - Hans Passant
是的,我的意思是指出当您在TP中运行通用同步代码时,#TP线程=#cores不是一件现实的事情,通常必须更大才能充分利用所有核心。另一个要点是相反的(假设您现在无法负担另一台机器或当前机器有数十个核心但没有单个应用程序同时使用那么多),由于其他具有自己线程池的进程,包括其他应用程序池,您可能需要使用较低的值。 - acelent

4
原因是QueueUserWorkItem:“将方法排队以执行。当线程池线程可用时,该方法执行。” https://msdn.microsoft.com/en-us/library/kbf0f1ct(v=vs.110).aspx
我理解的Threadpool只会缓慢增加线程数量以适应您的需求,这就是您在任务管理器中看到的情况。我认为您可以通过向您的线程添加一些事情来验证这一点。
编辑:我的意思是,您只需将它们排队,首个线程将启动,并且直到达到限制前,每500毫秒,https://blogs.msdn.microsoft.com/pedram/2007/08/05/dedicated-thread-or-a-threadpool-thread/会逐渐添加越来越多的线程 - 之后您仍然可以将新线程排队。

谢谢。那似乎是一个合理的解释。正如你所指出的,我完全忘记了我正在为执行排队线程,并且没有直接创建线程池线程的方法。 - Water Cooler v2

1

线程池(几乎)不会用尽线程。它有一个注入启发式算法,当它认为这有助于吞吐量时,以(几乎)无限的方式添加新线程。这也是防止基于可用线程太少的死锁的保护措施。

这可能是一个大问题,因为内存使用几乎没有上限。

"几乎"是因为有最大线程数,但在实践中往往非常高(数千个线程)。

当单次迭代我的for循环运行时,对于这些迭代中的某些迭代,没有线程池线程被创建。

从所显示的数据中,我无法理解原因。您可能应该在每次迭代之后测量Process.GetCurrentProcess().ThreadCount

也许在某些情况下避免了死锁?这不是确定性死锁。

在当前CLR上运行的线程似乎与操作系统线程1:1相对应。

也许你应该运行一个更简单的基准测试?

        for (int i = 0; i < 10000000; i++)
        {
            Task.Run(() => Thread.Sleep(Timeout.Infinite));

            int workerThreads;
            int cpThreads;
            ThreadPool.GetAvailableThreads(out workerThreads, out cpThreads);

            Console.WriteLine($"Queued: {i}, threads: {Process.GetCurrentProcess().Threads.Count}, workerThreads: {workerThreads}, workerThreads: {cpThreads}");

            Thread.Sleep(100);
        }

enter image description here


谢谢分享github上的讨论链接。那很有趣。我已经更新了我的代码和原始问题中的图片,以包括当前进程的总线程计数。你和Sebastian所说的很好地解释了它。你指出了ThreadPool在创建和委托新线程之前所做的启发式或决策,而Sebastian则指出我们只是把它们排队,对它们的创建没有控制。 - Water Cooler v2
在更新的代码中,我看到在创建任何线程池线程之前有14个线程。我原以为只有一个主线程,但我猜可能是因为我在调试器中运行代码,所以还有其他与调试器相关的线程。随着每次迭代,总线程数和线程池计数没有增加,正如你们所解释的那样,但这两个计数器保持同步,即当可用线程池线程计数减少一个时,线程计数增加一个。 - Water Cooler v2

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