.NET Framework 和 .NET Core 之间的线程池差异,线程池饥饿

Thread pool differences between .NET Framework and .NET Core, Thread Pool starvation

我在尝试将工作代码从 .Net Framework 4.6.1 传递到 .Net Core 3.1 时偶然发现了意外行为

这是代码的简化:

static  void Main(string[] args)
{
    for (int i = 0; i < 20; i++)
    {
        ThreadPool.QueueUserWorkItem(o =>
        {
            Console.Write($"In, ");
            RestClient restClient = new RestClient($"http://google.com");
            RestRequest restRequest = new RestRequest();
            var response = restClient.Get(restRequest);

            Console.Write($"Out, ");
        });
    }

    Console.ReadLine();
}

作为多线程工作的结果,控制台上的预期输出是一个“In”列表,后面跟着混合的“In”和“Out”,最后是一些“Out”。这在 .Net Framework 上按预期工作。 像这样:

In, In, In, In, In, In, In, In, In, In, In, In, In, In, In, Out, In, Out,
In, Out, In, Out, In, Out, In, Out, Out, Out, Out, Out, Out, Out, Out,
Out, Out, Out, Out, Out, Out, Out,

但是当 运行 .Net Core 3.1(同一台机器)上的完全相同的代码时,看起来我们只有在所有“in”线程完成后才返回写入“out”行(我测试过这比 20 多了很多)。

In, In, In, In, In, In, In, In, In, In, In, In, In, In, In, In, In, In,
In, In, Out, Out, Out, Out, Out, Out, Out, Out, Out, Out, Out, Out, Out,
Out, Out, Out, Out, Out, Out, Out,

意味着进程饥饿,如果添加到线程池的工作项数量是无限的(例如,取决于 API),HTTP 响应将永远不会被处理。

我认为这是因为 ThreadPool 算法选择下一个线程来处理的方式 this is a nice article on the subject

我不明白的是为什么它不会在 .Net Framework 上发生,以及我是否可以让它在 .Net Core 上以某种方式工作。

P.s。我并不是要避免与 TPL 合作,我只是想弄清楚这个问题。

有什么建议吗?

似乎问题在于线程池“旨在最大化吞吐量,而不是最小化延迟”。

我认为以下文章为理解此行为提供了一个很好的起点: Article

[编辑] 这是我发现的

.NET Core 和.NET Framework 的区别在于 HttpWebRequest.GetResponse() 的实现。在 .NET Framework 中,它使用 Thread.SpinWait(1),而在 .NET Core 中,它使用 SendRequest().GetAwaiter().GetResult() - 本质上调用异步实现并对其执行 Wait()。

异步方法调用依赖于 TaskScheduler 来执行延续。 TaskScheduler 依赖于 ThreadPool。

通常,线程池以 minThreads = # cores 开始。然后它使用一些算法慢慢地增加线程数直到达到maxThreads。

代码立即将 20 个阻塞作业发布到线程池。后续作业在它们之后排队。线程池会慢慢增加线程数以适应下载作业,然后才会添加一个线程来处理第一个 Continuation 作业。

另一个有趣的转折点是,如果您将最小线程和最大线程都设置为相同的低数字并且 运行 代码,它会死锁。那是因为 Continuation 永远不会收到要执行的线程。有关死锁的更多信息 here.

有多种方法可以解决这个问题

  1. 避免混合同步和异步代码。一路异步(如果可以的话)
  2. 使用ThreadPool.SetMinThreads以足够数量的线程启动。您至少需要与预期的并发下载作业数相同的线程数。
  3. 在示例代码中,即使您在发布下载作业之间添加 10-50 毫秒的延迟,后续作业也有机会在两者之间安排。

(这个问题使用了一个叫做 RestClient 的东西,它可能在后台使用 HttpClient 或 HttpWebRequest。下面的代码使用 HttpWebRequest)

private static void Main(string[] args)
{
    //ThreadPool.SetMinThreads(4, 4);
    //ThreadPool.SetMaxThreads(4, 4);
    for (var i = 0; i < 20; i++)
        ThreadPool.QueueUserWorkItem(o =>
        {
            Console.Write("In, ");

            var r = (HttpWebRequest) WebRequest.Create("http://google.com");
            r.GetResponse();
            //Try this in .Net Framework and get the same result in as in .NET Core.
            //That's because in .NET Core r.GetResponse() essentially does r.GetResponseAsync().Wait()
            //r.GetResponseAsync().Wait();  

            Console.Write("Out, ");
        });

    Console.ReadLine();
}