.Net 4.5中的异步HttpClient对于密集负载应用程序是否是错误的选择? - c#

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

参考方案

除了问题中提到的测试之外,我最近还创建了一些新的测试,它们涉及的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操作在启动它们的线程上执行时更加高效。

异步HTTP调用在处理较长或可能较长的I / O操作时是一个不错的选择,因为它不会使任何线程忙于等待I / O操作完成。这减少了应用程序使用的线程总数,从而使CPU绑定操作可以花费更多的CPU时间。此外,在仅分配有限数量的线程的应用程序上(就像Web应用程序一样),异步I / O可以防止线程池线程耗尽,如果同步执行I / O调用,则可能会发生线程耗尽。

因此,异步HttpClient并不是密集负载应用程序的瓶颈。只是,从本质上讲,它不是非常适合非常快速的HTTP请求,而是非常适合长的或可能很长的请求,尤其是在仅可用线程数量有限的应用程序内部。同样,一种很好的做法是通过ServicePointManager.DefaultConnectionLimit限制并发,该值必须足够高以确保良好的并行性,但又必须足够低以防止临时端口耗尽。您可以找到有关针对此问题here的测试和结论的更多详细信息。

45码