我最近创建了一个简单的应用程序来测试可以以异步方式生成的 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 组合似乎确保了可靠性(至少对于所有调用都针对同一主机进行的情况),但它的性能似乎仍因缺乏适当的节流机制而受到负面影响。将并发请求数限制为可配置值并将其余请求放入队列的东西将使其更适合高可伸缩性场景。