我最近创建了一个简单的应用程序来测试可以以异步方式生成的 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 的行为方式。由于传送 10k 条消息大约需要 2 秒,我认为传送 10 倍的消息需要大约 20 秒,但运行测试表明传送 100k 条消息需要大约 50 秒。此外,传送 200k 条消息通常需要超过 2 分钟的时间,而且通常有几千条(3-4k)条消息会失败,但以下情况除外:
由于系统缺少足够的缓冲区空间或队列已满,因此无法对套接字执行操作。
我检查了 IIS 日志和失败的操作从未到达服务器。他们在客户内部失败了。我在 Windows 7 机器上运行测试,临时端口的默认范围为 49152 到 65535。运行 netstat 显示测试期间使用了大约 5-6k 端口,因此理论上应该有更多可用端口。如果缺少端口确实是异常的原因,则意味着 netstat 没有正确报告情况,或者 HttClient 仅使用最大数量的端口,之后它开始抛出异常。
相比之下,生成 HTTP 调用的多线程方法表现得非常可预测。我花了大约 0.6 秒处理 10k 条消息,大约 5.5 秒处理 100k 条消息,正如预期的那样,处理 100 万条消息大约需要 55 秒。没有一条消息失败。此外,在运行时,它从未使用超过 55 MB 的 RAM(根据 Windows 任务管理器)。异步发送消息时使用的内存与负载成比例增长。在 200k 消息测试期间,它使用了大约 500 MB 的 RAM。
我认为造成上述结果的主要原因有两个。第一个是 HttpClient 在与服务器创建新连接时似乎非常贪婪。 netstat 报告的大量使用端口意味着它可能不会从 HTTP 保持活动中受益。
二是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 或通过配置),这意味着它永远不会耗尽端口,也永远不会在任何请求上失败(HttpClient,默认情况下,基于 HttpWebRequest ,但似乎忽略了连接限制设置)。
异步 HttpWebRequest 方法仍然比多线程方法慢 50% - 60%,但它是可预测且可靠的。唯一的缺点是它在大负载下使用了大量内存。例如,它需要大约 1.6 GB 来发送 100 万个请求。通过限制并发请求的数量(就像我上面对 HttpClient 所做的那样),我设法将使用的内存减少到仅 20 MB,并获得比多线程方法慢 10% 的执行时间。
在这个冗长的介绍之后,我的问题是:.Net 4.5 中的 HttpClient 类对于密集负载应用程序来说是不是一个糟糕的选择?有什么方法可以限制它,这应该可以解决我提到的问题吗? HttpWebRequest 的异步风格怎么样?
更新(感谢@Stephen Cleary)
事实证明,HttpClient 就像 HttpWebRequest(默认基于它)一样,可以通过 ServicePointManager.DefaultConnectionLimit 限制同一主机上的并发连接数。奇怪的是,根据 MSDN,连接限制的默认值为 2。我还使用调试器检查了这一点,它指出确实 2 是默认值。但是,似乎除非显式为 ServicePointManager.DefaultConnectionLimit 设置一个值,否则默认值将被忽略。由于我在 HttpClient 测试期间没有明确地为它设置一个值,我认为它被忽略了。
在将 ServicePointManager.DefaultConnectionLimit 设置为 100 之后,HttpClient 变得可靠且可预测(netstat 确认仅使用了 100 个端口)。它仍然比异步 HttpWebRequest 慢(大约 40%),但奇怪的是,它使用的内存更少。对于涉及 100 万个请求的测试,它使用了最大 550 MB,而异步 HttpWebRequest 中使用了 1.6 GB。
因此,虽然 HttpClient 与 ServicePointManager.DefaultConnectionLimit 组合似乎确保了可靠性(至少对于所有调用都针对同一主机进行的情况),但它的性能似乎仍因缺乏适当的节流机制而受到负面影响。将并发请求数限制为可配置值并将其余请求放入队列的东西将使其更适合高可伸缩性场景。
HttpClient
应该尊重 ServicePointManager.DefaultConnectionLimit
。
SemaphoreSlim
(如前所述)或 TPL Dataflow 中的 ActionBlock<T>
。
除了问题中提到的测试之外,我最近还创建了一些新的测试,这些测试涉及更少的 HTTP 调用(5000 个,而之前的 100 万个),但请求的执行时间要长得多(500 毫秒,而之前的大约 1 毫秒)。两个测试应用程序,同步多线程应用程序(基于 HttpWebRequest)和异步 I/O 应用程序(基于 HTTP 客户端)产生了相似的结果:使用大约 3% 的 CPU 和 30 MB 内存执行大约 10 秒。两个测试器之间的唯一区别是多线程的一个使用 310 个线程来执行,而异步的只有 22 个。因此,在一个结合了 I/O 绑定和 CPU 绑定操作的应用程序中,异步版本会产生更好的结果因为执行 CPU 操作的线程会有更多可用的 CPU 时间,而这些线程实际上需要它(等待 I/O 操作完成的线程只是在浪费)。
作为我的测试的结论,在处理非常快的请求时,异步 HTTP 调用不是最佳选择。其背后的原因是,当运行包含异步 I/O 调用的任务时,一旦进行异步调用,启动任务的线程就会退出,并将任务的其余部分注册为回调。然后,当 I/O 操作完成时,回调将排队等待在第一个可用线程上执行。所有这些都会产生开销,这使得快速 I/O 操作在启动它们的线程上执行时更加高效。
在处理长或可能长的 I/O 操作时,异步 HTTP 调用是一个不错的选择,因为它不会让任何线程忙于等待 I/O 操作完成。这减少了应用程序使用的线程总数,允许 CPU 绑定操作花费更多 CPU 时间。此外,在只分配有限数量线程的应用程序上(就像 Web 应用程序的情况一样),异步 I/O 可以防止线程池线程耗尽,如果同步执行 I/O 调用可能会发生这种情况。
因此,异步 HttpClient 不是密集负载应用程序的瓶颈。只是就其本质而言,它不太适合非常快速的 HTTP 请求,而是非常适合长或可能长的请求,尤其是在只有有限数量可用线程的应用程序中。此外,通过 ServicePointManager.DefaultConnectionLimit 限制并发性是一个很好的做法,其值足够高以确保良好的并行度,但又足够低以防止临时端口耗尽。您可以找到有关此问题 here 的测试和结论的更多详细信息。
要考虑的可能会影响您的结果的一件事是,使用 HttpWebRequest 您不会获得 ResponseStream 并使用该流。使用 HttpClient,默认情况下它将网络流复制到内存流中。为了以与当前使用 HttpWebRquest 相同的方式使用 HttpClient,您需要这样做
var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);
另一件事是我不确定真正的区别是什么,从线程的角度来看,你实际上是在测试。如果您深入研究 HttpClientHandler ,它只会执行 Task.Factory.StartNew 以执行异步请求。线程行为被委派给同步上下文,其方式与您的 HttpWebRequest 示例完全相同。
毫无疑问,HttpClient 会增加一些开销,因为默认情况下它使用 HttpWebRequest 作为其传输库。因此,在使用 HttpClientHandler 时,您将始终能够直接通过 HttpWebRequest 获得更好的性能。 HttpClient 带来的好处是标准类,如 HttpResponseMessage、HttpRequestMessage、HttpContent 和所有强类型标头。它本身并不是性能优化。
HttpClient
似乎很容易使用,我认为异步是要走的路,但似乎有很多“但是和如果”。也许HttpClient
应该重写,以便使用起来更直观?或者文档真的在强调如何最有效地使用它的重要事项?
虽然这并没有直接回答 OP 问题的“异步”部分,但这解决了他正在使用的实现中的一个错误。
如果您希望您的应用程序可扩展,请避免使用基于实例的 HttpClients。区别是巨大的!根据负载,您将看到非常不同的性能数字。 HttpClient 旨在跨请求重用。编写它的 BCL 团队的人证实了这一点。
我最近的一个项目是帮助一家非常大的知名在线计算机零售商扩展黑色星期五/假日流量的一些新系统。我们在使用 HttpClient 时遇到了一些性能问题。由于它实现了 IDisposable
,因此开发人员通过创建一个实例并将其放在 using()
语句中来完成您通常会做的事情。一旦我们开始对应用程序进行负载测试,服务器就会崩溃——是的,服务器不仅仅是应用程序。原因是 HttpClient 的每个实例都会在服务器上打开一个 I/O 完成端口。由于 GC 的非确定性最终确定以及您正在使用跨多个 OSI layers 的计算机资源这一事实,关闭网络端口可能需要一段时间。事实上,Windows 操作系统本身最多可能需要 20 秒才能关闭一个端口(根据 Microsoft)。我们打开端口的速度比关闭端口的速度要快——服务器端口耗尽导致 CPU 达到 100%。我的解决方法是将 HttpClient 更改为解决问题的静态实例。是的,它是一种一次性资源,但性能差异远远超过了任何开销。我鼓励您进行一些负载测试,以了解您的应用程序的行为方式。
也在下面的链接中回答:
What is the overhead of creating a new HttpClient per call in a WebAPI client?
https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client
不定期副业成功案例分享