我最近创建了一个用于测试 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 似乎确保了可靠性(至少在所有调用都发送到同一个主机的场景中是如此) ,但它的性能似乎仍然受到缺乏适当节流机制的负面影响。将并发请求数限制为可配置值并将其余请求放在队列中的做法将使其更适合于高可伸缩性场景。