我最近创建了一个简单的应用程序来测试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();
}
}
运行测试表明,多线程版本速度更快。完成1万个请求花了大约0.6秒,而异步处理花了大约2秒就完成了相同的负载。这有点令人惊讶,因为我期望异步程序更快。也许是因为我的HTTP调用非常快。在现实世界中,服务器应该执行更有意义的操作,并且还应该存在一些网络延迟,结果可能会相反。
但是,真正让我担心的是增加负载时HttpClient的行为方式。由于传递1万条消息大约需要2秒钟,因此我认为传递10倍的消息数量大约需要20秒,但是运行测试表明,传递10万条消息大约需要50秒。此外,传递200k消息通常花费超过2分钟的时间,并且通常有数千个消息(3-4k)失败,但以下情况除外:
由于系统缺少足够的缓冲区空间或队列已满,无法对套接字执行操作。
我检查了失败的IIS日志和操作,这些日志从未到达服务器。他们在客户端内部失败。我在Windows 7计算机上运行了默认范围为49152到65535的临时端口的测试。运行netstat显示,在测试期间使用了大约5-6k个端口,因此从理论上讲应该有更多可用的端口。如果缺少端口确实是导致异常的原因,则意味着netstat不能正确报告情况,或者HttClient仅使用最大数量的端口,之后它开始引发异常。
相比之下,生成HTTP调用的多线程方法的行为非常可预测。对于1万条消息,我花了大约0.6秒,对于10万条消息,我花了大约5.5秒,而对于100万条消息,我花了大约55秒。没有消息失败。此外,它运行时从未使用超过55 MB的RAM(根据Windows Task Manager)。异步发送消息时使用的内存与负载成比例增长。在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或通过config),这意味着它永远不会耗尽端口,也不会因任何请求而失败(默认情况下,HttpClient基于HttpWebRequest ,但似乎忽略了连接限制设置)。
异步HttpWebRequest方法仍比多线程方法慢约50-60%,但它是可预测且可靠的。唯一的缺点是它在大负载下使用了大量的内存。例如,发送1百万个请求大约需要1.6 GB。通过限制并发请求的数量(就像我上面对HttpClient所做的那样),我设法将使用的内存减少到仅20 MB,并且获得的执行时间仅比多线程方法慢10%。
在冗长的演示之后,我的问题是:.Net 4.5中的HttpClient类对于密集负载应用程序是否是一个错误的选择?有什么办法可以解决这个问题,应该解决我提到的问题吗?HttpWebRequest的异步风格如何?
更新(感谢@Stephen Cleary)
事实证明,HttpClient与HttpWebRequest(默认基于HttpWebRequest)一样,可以在同一主机上通过ServicePointManager.DefaultConnectionLimit限制其并发连接数。奇怪的是,根据MSDN,连接限制的默认值为2。我还使用调试器检查了这一点,调试器指出确实2是默认值。但是,似乎除非明确为ServicePointManager.DefaultConnectionLimit设置值,否则默认值将被忽略。由于我在HttpClient测试期间未明确为其设置值,因此我认为它已被忽略。
将ServicePointManager.DefaultConnectionLimit设置为100后,HttpClient变得可靠且可预测(netstat确认仅使用了100个端口)。它仍然比异步HttpWebRequest慢(大约40%),但是奇怪的是,它使用的内存更少。对于涉及一百万个请求的测试,它使用了最大550 MB的内存,而异步HttpWebRequest中则为1.6 GB。
因此,虽然HttpClient与ServicePointManager.DefaultConnectionLimit结合使用似乎可以确保可靠性(至少对于所有呼叫都向同一主机进行调用的情况而言),但看起来它的性能仍然受到缺乏适当节流机制的负面影响。如果将某种限制将并发请求的数量限制为可配置的值,然后将其余的请求放入队列中,那么它将更适合于高可伸缩性方案。
SemaphoreSlim
,如前所述,或ActionBlock<T>
从TPL Dataflow中使用。
HttpClient
应该尊重ServicePointManager.DefaultConnectionLimit
。