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


130

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


4
HttpClient应该尊重ServicePointManager.DefaultConnectionLimit
Stephen Cleary

2
您的观察结果似乎值得调查。不过,有一件事困扰着我:我认为一次发布数千个异步IO的做法非常不合理。我永远不会在生产中这样做。您异步的事实并不意味着您可以疯狂消耗各种资源。(在这方面,Microsoft的官方示例也有一些误导。)
usr

1
但是,不要因时间延迟而节流。根据经验确定的固定并发级别上的节流阀。一个简单的解决方案是SemaphoreSlim.WaitAsync,尽管它也不适合于任意数量的任务。
usr

1
@FlorinDumitrescu对于节流,可以使用SemaphoreSlim,如前所述,或ActionBlock<T>从TPL Dataflow中使用。
svick

1
@svick,感谢您的建议。我对手动实现限制/并发限制的机制不感兴趣。如前所述,我的问题中包含的实现仅用于测试和验证理论。我不会尝试改进它,因为它不会投入生产。我感兴趣的是.Net框架是否提供了一种用于限制异步IO操作(包括HttpClient)的并发性的内置机制。
Florin Dumitrescu

Answers:


64

除了问题中提到的测试外,我最近还创建了一些新的测试,涉及的HTTP调用少得多(5000个,以前为100万个),但是对请求的执行时间要长得多(500毫秒,而以前为1毫秒)。同步多线程应用程序(基于HttpWebRequest)和异步I / O应用程序(基于HTTP客户端)这两个测试器应用程序都产生了相似的结果:使用大约3%的CPU和30 MB的内存执行大约10秒。这两个测试器之间的唯一区别是,多线程测试者使用310个线程来执行,而异步测试者只有22个线程。

作为我测试的结论,在处理非常快速的请求时,异步HTTP调用不是最佳选择。其背后的原因是,当运行包含异步I / O调用的任务时,启动该线程的线程将在进行异步调用后立即退出,并将该任务的其余部分注册为回调。然后,当I / O操作完成时,回调将排队等待在第一个可用线程上执行。所有这些都会产生开销,这使得快速的I / O操作在启动它们的线程上执行时更加高效。

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

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


3
“非常快”有多快?1毫秒?100毫秒?1000毫秒?
Tim P.

我正在使用类似“异步”方法的方法来重播Windows上部署的WebLogic Web服务器上的负载,但是我很快遇到了短暂的端口耗尽问题。我还没有碰到ServicePointManager.DefaultConnectionLimit,而是正在处理和重新创建每个请求的所有内容(HttpClient和响应)。您是否知道什么可能导致连接保持打开状态并耗尽端口?
Iravanchi 2014年

@TimP。对于我的测试,如上所述,“非常快”是仅需1毫秒即可完成的请求。在现实世界中,这始终是主观的。从我的角度来看,相当于在本地网络数据库上进行小查询的事物可以被认为是快速的,而相当于通过Internet进行API调用的事物可以被认为是缓慢的或潜在的缓慢。
Florin Dumitrescu 2014年

1
@Iravanchi,在“异步”方法中,请求发送和响应处理是分别执行的。如果您有很多电话,所有请求都将非常快速地发送,并且响应将在到达时得到处理。由于您只能在响应到达后才处置连接,因此大量并发连接会累积并耗尽您的临时端口。您应该使用ServicePointManager.DefaultConnectionLimit限制最大并发连接数。
Florin Dumitrescu 2014年

1
@FlorinDumitrescu,我还要补充一点,网络调用本质上是不可预测的。当网络资源拥塞或在其他10%的时间内不可用时,在90%的时间内在10毫秒内运行的事物可能导致阻塞问题。
蒂姆·P.

27

要考虑的可能影响您的结果的一件事是,使用HttpWebRequest时,您不会获取ResponseStream并使用该流。使用HttpClient,默认情况下它将把网络流复制到内存流中。为了以与当前使用HttpWebRquest相同的方式使用HttpClient,您需要执行以下操作

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

另一件事是,我不确定从线程角度来看,您实际上正在测试的真正区别是什么。如果深入研究HttpClientHandler的深度,它只是执行Task.Factory.StartNew以便执行异步请求。线程行为以与HttpWebRequest示例中的示例完全相同的方式委派给同步上下文。

毫无疑问,HttpClient会增加一些开销,因为默认情况下它使用HttpWebRequest作为其传输库。因此,在使用HttpClientHandler时,始终可以直接通过HttpWebRequest获得更好的性能。HttpClient带来的好处是带有HttpResponseMessage,HttpRequestMessage,HttpContent和所有强类型标头的标准类。它本身不是性能优化。


(答案是旧的,但)HttpClient似乎很容易使用,我认为异步是必经之路,但是似乎有很多“ buts and ifs”。也许HttpClient应该重写以便使用起来更直观?还是该文档确实在强调如何最有效地使用它的重要事项?
mortb '17

@mortb,Flurl.Http flurl.io是使用HttpClient的包装器更直观的方法
Michael Freidgeim

1
@MichaelFreidgeim:谢谢,尽管我现在已经学会了与HttpClient一起生活……
mortb '18

17

尽管这不能直接回答OP问题的“异步”部分,但这可以解决他正在使用的实现中的错误。

如果要扩展应用程序,请避免使用基于实例的HttpClients。不同之处在于巨大!根据负载,您将看到非常不同的性能数字。HttpClient旨在跨请求重复使用。这是由BCL团队中撰写此文件的人确认的。

我最近的一个项目是帮助一个非常大的知名在线计算机零售商扩展黑色星期五/节假日的流量,以购买一些新系统。我们在使用HttpClient时遇到了一些性能问题。既然实现了IDisposable,开发人员就可以通过创建实例并将其放置在using()语句中来完成您通常要做的事情。一旦我们开始进行负载测试,该应用程序便使服务器屈服-是的,服务器不仅是该应用程序。原因是每个HttpClient实例都会在服务器上打开一个I / O完成端口。由于GC的确定性不确定,并且您正在使用跨多个OSI层的计算机资源,因此关闭网络端口可能需要一段时间。实际上Windows OS 本身最多可能需要20秒才能关闭端口(每个Microsoft)。我们打开端口的速度比关闭端口的速度快-服务器端口耗尽将CPU占用了100%。我的解决方法是将HttpClient更改为可解决问题的静态实例。是的,它是可支配的资源,但是性能差异大大超过了任何开销。我鼓励您进行一些负载测试,以了解您的应用程序的行为。

在下面的链接中也有回答:

在WebAPI客户端中,每个调用创建一个新的HttpClient的开销是多少?

https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client


我发现在客户端上创建TCP端口耗尽的问题完全相同。解决方案是在进行迭代调用时长时间租用HttpClient实例,而不是为每个调用创建和处置。我得出的结论是“仅仅因为它实现了Dispose,这并不意味着它便宜”。
PhillipH '16

因此,如果HttpClient是静态的,并且我需要在下一个请求上更改标头,则对第一个请求有什么作用?因为它是静态的,所以更改HttpClient是否有任何危害-例如发出HttpClient.DefaultRequestHeaders.Accept.Clear();。?例如,如果我有通过令牌进行身份验证的用户,则这些令牌需要作为请求的标头添加到API,这些标头是不同的令牌。不会将HttpClient设置为静态,然后在HttpClient上更改此标头会产生不利影响吗?
crizzwald '16

如果您需要使用HttpClient实例成员(例如标头/ Cookie等),则不应使用静态HttpClient。否则,您的实例数据(标头,Cookie)对于每个请求都是相同的-肯定不是您想要的。
戴夫·布莱克

既然是这种情况...您将如何防止上面在帖子中描述的内容-负载不足?负载均衡器并向其添加更多服务器?
crizzwald,2016年

@crizzwald-在我的帖子中,我指出了所使用的解决方案。使用HttpClient的静态实例。如果您需要在HttpClient上使用标头/ Cookie,我希望使用其他方法。
戴夫·布莱克
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.