来自.Net 4.5的异步 HttpClient 对于密集负载应用程序来说是一个糟糕的选择吗?

我最近创建了一个用于测试 HTTP 调用吞吐量的简单应用程序,该应用程序可以以异步方式生成,而不是经典的多线程方法。

应用程序能够执行预定义数量的 HTTP 调用,并在最后显示执行这些调用所需的总时间。在测试期间,所有 HTTP 调用都发送到我的本地 IIS 服务器,并检索到一个小文本文件(大小为12字节)。

异步实现代码的最重要部分如下:

public async void TestAsync()
{
this.TestInit();
HttpClient httpClient = new HttpClient();


for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
ProcessUrlAsync(httpClient);
}
}


private async void ProcessUrlAsync(HttpClient httpClient)
{
HttpResponseMessage httpResponse = null;


try
{
Task<HttpResponseMessage> getTask = httpClient.GetAsync(URL);
httpResponse = await getTask;


Interlocked.Increment(ref _successfulCalls);
}
catch (Exception ex)
{
Interlocked.Increment(ref _failedCalls);
}
finally
{
if(httpResponse != null) httpResponse.Dispose();
}


lock (_syncLock)
{
_itemsLeft--;
if (_itemsLeft == 0)
{
_utcEndTime = DateTime.UtcNow;
this.DisplayTestResults();
}
}
}

多线程实现的最重要部分如下:

public void TestParallel2()
{
this.TestInit();
ServicePointManager.DefaultConnectionLimit = 100;


for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
Task.Run(() =>
{
try
{
this.PerformWebRequestGet();
Interlocked.Increment(ref _successfulCalls);
}
catch (Exception ex)
{
Interlocked.Increment(ref _failedCalls);
}


lock (_syncLock)
{
_itemsLeft--;
if (_itemsLeft == 0)
{
_utcEndTime = DateTime.UtcNow;
this.DisplayTestResults();
}
}
});
}
}


private void PerformWebRequestGet()
{
HttpWebRequest request = null;
HttpWebResponse response = null;


try
{
request = (HttpWebRequest)WebRequest.Create(URL);
request.Method = "GET";
request.KeepAlive = true;
response = (HttpWebResponse)request.GetResponse();
}
finally
{
if (response != null) response.Close();
}
}

运行测试显示多线程版本更快。完成10k 请求大约需要0.6秒,而异步请求完成相同负载大约需要2秒。这有点出乎意料,因为我原以为异步操作会更快。也许是因为我的 HTTP 调用非常快。在真实的场景中,服务器应该执行更有意义的操作,而且还应该存在一些网络延迟,结果可能是相反的。

然而,真正让我担心的是 HttpClient 在负载增加时的行为方式。因为传递10000条信息需要大约2秒钟,所以我认为传递10倍数量的信息需要大约20秒钟,但是运行测试表明,传递100000条信息需要大约50秒钟。此外,传递20万条消息通常需要超过2分钟的时间,而且通常有几千条(3-4k)消息会失败,但以下例外情况除外:

由于系统缺乏足够的缓冲区空间或队列已满,无法执行套接字上的操作。

我检查了失败的 IIS 日志和操作从未到达服务器。他们在客户内部失败了。我在 Windows7机器上运行了测试,默认的临时端口范围是49152到65535。运行 netstat 显示,测试期间使用了大约5-6k 端口,因此理论上应该有更多的可用端口。如果端口的缺乏确实是异常的原因,那就意味着 netstat 没有正确地报告这种情况,或者 HttClient 只使用了最大数量的端口,之后就开始抛出异常。

相比之下,生成 HTTP 调用的多线程方法表现得非常可预测。100000条信息大约需要0.6秒,100000条信息大约需要5.5秒,正如预期的那样,100000条信息大约需要55秒。没有一条消息失败。此外,当它运行时,它从来没有使用超过55MB 的内存(根据 Windows 任务管理器)。异步发送消息时使用的内存与负载成比例增长。在200k 消息测试期间,它使用了大约500MB 的 RAM。

我认为造成上述结果的主要原因有两个。第一个问题是 HttpClient 在创建与服务器的新连接时似乎非常贪婪。Netstat 报告了大量使用过的端口,这意味着它可能不会从 HTTP keep-alive 中获得太多好处。

其次,HttpClient 似乎没有节流机制。事实上,这似乎是一个与异步操作相关的普遍问题。如果您需要执行大量操作,那么它们将同时启动,然后在可用时执行它们的延续操作。理论上这应该没问题,因为在异步操作中,负载由外部系统承担,但是如上所述,情况并非完全如此。同时启动大量请求将增加内存使用并减慢整个执行速度。

通过使用一种简单但原始的延迟机制来限制异步请求的最大数量,我设法获得了更好的结果、内存和执行时间:

public async void TestAsyncWithDelay()
{
this.TestInit();
HttpClient httpClient = new HttpClient();


for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
{
if (_activeRequestsCount >= MAX_CONCURENT_REQUESTS)
await Task.Delay(DELAY_TIME);


ProcessUrlAsyncWithReqCount(httpClient);
}
}

如果 HttpClient 包含一种限制并发请求数量的机制,那么它将非常有用。使用 Task 类时(该类基于。Net 线程池)调节是通过限制并发线程的数量自动实现的。

为了获得完整的概述,我还创建了一个基于 HttpWebRequest 而不是 HttpClient 的异步测试版本,并设法获得了更好的结果。首先,它允许设置并发连接的数量限制(使用 ServicePointManager)。DefaultConnectionlimit 或 via config) ,这意味着它从未用完端口,也从未在任何请求上失败(HttpClient,默认情况下,是基于 HttpWebRequest 的,但它似乎忽略了连接限制设置)。

异步 HttpWebRequest 方法仍然比多线程方法慢50-60% ,但是它是可预测和可靠的。它唯一的缺点是在大负载下使用了大量的内存。例如,它需要大约1.6 GB 来发送100万个请求。通过限制并发请求的数量(就像我上面为 HttpClient 所做的那样) ,我设法将使用的内存减少到仅仅20MB,并且比多线程方法的执行时间只慢10% 。

在这个冗长的演示之后,我的问题是: HttpClient 类是否来自。Net 4.5对于密集负载应用来说是一个糟糕的选择吗?有没有什么方法可以控制它,来解决我提到的问题?HttpWebRequest 的异步风格如何?

更新(谢谢@Stephen Cleary)

事实证明,HttpClient 和 HttpWebRequest (默认情况下基于它)一样,可以使用 ServicePointManager 限制同一主机上的并发连接数量。缺省连接限制。奇怪的是,根据 MSDN,连接限制的默认值是2。我还使用调试器在我这边检查了一下,它指出的确2是默认值。但是,似乎除非显式地将值设置为 ServicePointManager。缺省值将被忽略。因为在 HttpClient 测试期间没有显式地为它设置值,所以我认为它被忽略了。

设置 ServicePointManager 之后。DefaultConnectionLimitto100HttpClient 变得可靠和可预测(netstat 确认只使用了100个端口)。它仍然比异步 HttpWebRequest 慢(大约40%) ,但奇怪的是,它使用更少的内存。对于涉及100万个请求的测试,它最多使用550 MB,而异步 HttpWebRequest 使用1.6 GB。

因此,当 HttpClient 与 ServicePointManager 组合时。DefaultConnectionlimit 似乎确保了可靠性(至少在所有调用都发送到同一个主机的场景中是如此) ,但它的性能似乎仍然受到缺乏适当节流机制的负面影响。将并发请求数限制为可配置值并将其余请求放在队列中的做法将使其更适合于高可伸缩性场景。

47713 次浏览

需要考虑的一件事情可能会影响您的结果,那就是使用 HttpWebRequest 时,您不会获得 ResponseStream 并使用该流。使用 HttpClient,默认情况下它会将网络流复制到内存流中。为了使用 HttpClient 的方式与您目前使用 HttpWebRrequest 的方式相同,您需要这样做

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

另一件事是,我真的不确定,从线程的角度来看,真正的区别是什么,你实际上是在测试。如果深入研究 HttpClientHandler,它只是简单地执行 Task。工厂。StartNew 以执行异步请求。将线程行为委托给同步上下文的方式与使用 HttpWebRequest 示例完成的示例完全相同。

毫无疑问,HttpClient 增加了一些开销,因为默认情况下它使用 HttpWebRequest 作为它的传输库。因此,当您使用 HttpClientHandler 时,您总是能够直接使用 HttpWebRequest 获得更好的性能。HttpClient 带来的好处是使用标准类,如 HttpResponseMessage、 HttpRequestMessage、 HttpContent 和所有强类型头。它本身并不是一种性能优化。

除了问题中提到的测试之外,我最近还创建了一些新的测试,它们涉及的 HTTP 调用要少得多(5000次比之前的100万次少得多) ,但是要执行的请求要长得多(500毫秒比之前的1毫秒多)。这两个测试程序,同步多线程程序(基于 HttpWebRequest)和异步 I/O 程序(基于 HTTP 客户端)产生了相似的结果: 大约10秒执行,使用大约3% 的 CPU 和30MB 的内存。两个测试人员之间的唯一区别是,多线程测试人员使用310个线程来执行,而异步测试人员只使用22个线程。因此,在一个将 I/O 绑定和 CPU 绑定操作结合在一起的应用程序中,异步版本会产生更好的结果,因为执行 CPU 操作的线程有更多的 CPU 时间可用,而这些线程实际上需要 CPU 时间(等待 I/O 操作完成的线程只是在浪费时间)。

作为我的测试的结论,当处理非常快的请求时,异步 HTTP 调用不是最好的选择。其背后的原因是,当运行包含异步 I/O 调用的任务时,一旦发出异步调用,启动任务的线程就会退出,任务的其余部分被注册为回调。然后,当 I/O 操作完成时,回调将在第一个可用线程上排队执行。所有这些都会造成开销,这使得在启动它们的线程上执行快速 I/O 操作时更有效率。

异步 HTTP 调用是处理长时间或可能长时间的 I/O 操作时的一个很好的选择,因为它不会让任何线程忙于等待 I/O 操作完成。这减少了应用程序使用的线程总数,从而允许 CPU 绑定操作花费更多的 CPU 时间。此外,对于只分配有限数量线程的应用程序(就像 Web 应用程序一样) ,异步 I/O 可以防止线程池线程消耗,如果同步执行 I/O 调用,就会发生这种情况。

因此,异步 HttpClient 不是密集负载应用程序的瓶颈。就其本质而言,它不太适合于非常快的 HTTP 请求,相反,它非常适合于长或潜在的长请求,特别是在只有有限数量线程可用的应用程序内部。此外,通过 ServicePointManager 限制并发性也是一种很好的做法。DefaultConnectionlimit 的值应该足够高以确保良好的并行性,但也应该足够低以防止短暂的端口损耗。您可以找到关于这个问题 给你的测试和结论的更多细节。

虽然这并不能直接回答 OP 问题中的“异步”部分,但是这解决了他正在使用的实现中的一个错误。

如果希望应用程序可伸缩,请避免使用基于实例的 HttpClients。区别是巨大的!根据负载的不同,您将看到非常不同的性能数字。HttpClient 被设计为跨请求重用。BCL 团队的人证实了这一点。

我最近的一个项目是帮助一个非常大和著名的在线电脑零售商扩大黑色星期五/假日流量的一些新系统。我们在使用 HttpClient 时遇到了一些性能问题。因为它实现了 IDisposable,所以开发人员做了您通常会做的事情: 创建一个实例并将其放在 using()语句中。一旦我们开始负载测试的应用程序带来了它的膝盖服务器-是的,服务器不只是应用程序。原因是 HttpClient 的每个实例都在服务器上打开一个 I/O 完成端口。由于 GC 的终止是不确定的,而且您正在处理跨多个 OSI 层的计算机资源,因此关闭网络端口可能需要一段时间。事实上,WindowsOS本身关闭一个端口最多需要20秒(每个微软)。我们打开端口的速度比关闭服务器端口的速度还要快——服务器端口的耗尽使得中央处理器达到了100% 。我的解决办法是将 HttpClient 更改为静态实例,从而解决了这个问题。是的,它是一种可任意使用的资源,但是性能上的差异远远超过了任何开销。我鼓励您做一些负载测试,看看您的应用程序是如何工作的。

下面的链接也回答了这个问题:

在 WebAPI 客户机中每次调用创建一个新的 HttpClient 的开销是多少?

Https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client