我最近创建了一个简单的应用程序,用于测试可以以异步方式生成的 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 调用的多线程方法的行为非常可预测。对于 10k 条消息,我大约需要 0.6 秒,对于 100k 条消息,大约需要 5.5 秒,正如预期,对于 100 万条消息,大约需要 55 秒。没有一条消息失败。此外,在运行时,它从未使用超过 55 MB 的 RAM(根据 Windows 任务管理器)。异步发送消息时使用的内存随着负载成比例增长。在 20 万条消息测试期间,它使用了大约 500 MB 的 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 或通过配置),这意味着它永远不会耗尽端口,也不会在任何请求上失败(HttpClient 默认情况下基于 HttpWebRequest ,但似乎忽略了连接限制设置)。
异步 HttpWebRequest 方法仍然比多线程方法慢 50 - 60% 左右,但它是可预测且可靠的。它唯一的缺点是在大负载下使用大量内存。例如,发送 100 万个请求需要大约 1.6 GB。通过限制并发请求的数量(就像我上面对 HttpClient 所做的那样),我设法将使用的内存减少到仅 20 MB,并且执行时间仅比多线程方法慢 10%。
在这个冗长的演示之后,我的问题是:.Net 4.5 中的 HttpClient 类对于密集负载应用程序来说是一个糟糕的选择吗?有什么办法可以限制它,从而解决我提到的问题吗? HttpWebRequest 的异步风格怎么样?
更新(感谢@Stephen Cleary)
事实证明,HttpClient 就像 HttpWebRequest(默认情况下基于它)一样,可以通过 ServicePointManager.DefaultConnectionLimit 限制同一主机上的并发连接数。奇怪的是,根据MSDN http://msdn.microsoft.com/en-us/library/system.net.servicepointmanager.defaultconnectionlimit.aspx,连接限制的默认值为 2。我还使用调试器检查了这一点,它指出 2 确实是默认值。但是,似乎除非明确为 ServicePointManager.DefaultConnectionLimit 设置值,否则默认值将被忽略。由于我在 HttpClient 测试期间没有明确设置它的值,因此我认为它被忽略了。
将 ServicePointManager.DefaultConnectionLimit 设置为 100 HttpClient 变得可靠且可预测(netstat 确认仅使用 100 个端口)。它仍然比异步 HttpWebRequest 慢(大约 40%),但奇怪的是,它使用的内存更少。对于涉及 100 万个请求的测试,它最多使用 550 MB,而异步 HttpWebRequest 则使用 1.6 GB。
因此,虽然 HttpClient 与 ServicePointManager.DefaultConnectionLimit 的组合似乎可以确保可靠性(至少对于所有调用都针对同一主机的情况),但它的性能看起来仍然因缺乏适当的限制机制而受到负面影响。将并发请求数限制为可配置值并将其余请求放入队列中的方法将使其更适合高可扩展性场景。