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

8

当我试图将工作代码从.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 算法选择下一个要处理的线程的方式导致的 这篇文章对此进行了很好的阐述

我不明白的是为什么在 .Net Framework 上不会发生这种情况,以及我是否可以在 .Net Core 上进行一些设置,使其正常工作。

P.s. 我并不是想避免使用 TPL,我只是想深入了解问题根源。

有什么建议吗?


4
如果你用类似 Thread.Sleep(500) 的简单阻塞调用替换 REST 调用,这个问题会不会也发生?我问这个是为了排除差异是与 RestClient 而非 ThreadPool 相关的可能性。 - Theodor Zoulias
2
“意味着该进程出现了饥饿”,请解释您是如何得出这个结论的。 - Ian Kemp
1
@TheodorZoulias 我使用sleep测试后,发现它按预期执行。但是,使用rest客户端或常规http请求时,在.net core 8(num cores)上,线程开始执行,然后池添加1个以上的线程,直到20个,只有在此之后才开始完成请求。我不知道为什么会出现这种情况。没有等待,所以不是关于排队连续性的问题。 - Evk
1
我不太确定这是关于什么的。但是假设请求自然需要大约1秒钟的时间。那么我期望会启动8个线程,然后每秒左右线程池会添加几个新线程,然后请求应该开始完成,因为您的请求是同步的,并且它已经在线程池线程上执行 - 它不需要从池中获取“新”线程来完成请求。在文章中描述的饥饿现象发生在请求是异步的并且需要从池中获取“新”线程来执行异步后续操作时。 - Evk
2
@NetanelSwartz,你的问题非常有趣。可能是.NET Framework和.NET Core之间的ThreadPool实现发生了变化。我找不到任何相关信息。 - Lukasz Szczygielek
显示剩余13条评论
2个回答

14

[编辑] 这是我发现的内容

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

异步方法调用依赖于TaskScheduler执行继续操作。TaskScheduler依赖于线程池。

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

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

另一个有趣的转折是,如果将min和max线程都设置为相同的较低数字并运行代码,则会死锁。这是因为Continuation永远不会收到要执行的线程。有关死锁的更多信息在这里

有多种方法可以解决此问题

  1. 避免混合同步和异步代码。一路上都使用异步(如果可以)
  2. 使用ThreadPool.SetMinThreads以足够数量的线程开始。您需要至少与预期并发下载作业数量相同的线程数。
  3. 在示例代码中,如果在发布下载作业之间添加10-50ms的延迟,则Continuation作业有机会在其中安排。

(该问题使用名为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();
}

但是问题中并没有GetAsync().Wait(...),也没有使用HttpClient。我尝试着用HttpWebRequest(其中有同步的Get方法)来做相同的事情。 - Evk
原始问题使用RestClient。不确定它确切的含义,但很可能在底层使用HttpClient。这是HttpWebRequest.GetResponse的实现:返回SendRequest().GetAwaiter().GetResult()。 - Alon Catz
仍然不知道为什么在.NET Framework和.NET Core之间工作方式不同。 - Lukasz Szczygielek
1
编辑了答案以反映更好的发现,并回答了.NET FW和.NET CORE之间的区别。 - Alon Catz

0

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

我认为以下文章可以作为了解这种行为的良好起点: 文章


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