来自.Net 4.5的异步HttpClient对于密集负载应用程序来说是一个糟糕的选择吗?

Flo*_*scu 129 c# asynchronous async-await .net-4.5 dotnet-httpclient

我最近创建了一个用于测试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();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

下面列出了多线程实现中最重要的部分:

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();
    }
}
Run Code Online (Sandbox Code Playgroud)

运行测试显示多线程版本更快.完成10k请求需要大约0.6秒,而异步版需要大约2秒才能完成相同的负载量.这有点令人惊讶,因为我期待异步更快.也许是因为我的HTTP调用非常快.在实际情况中,服务器应执行更有意义的操作,并且还应存在一些网络延迟,结果可能会相反.

然而,真正令我担忧的是当负载增加时HttpClient的行为方式.由于传送10k消息需要大约2秒钟,我认为它需要大约20秒才能传送10倍的消息,但运行测试显示需要大约50秒才能传送100k消息.此外,通常需要超过2分钟才能发送200k消息,并且通常会有数千个消息(3-4k)失败,并出现以下异常:

无法执行套接字上的操作,因为系统缺少足够的缓冲区空间或队列已满.

我检查了IIS日志和从未进入服务器的操作.他们在客户端失败了.我在Windows 7机器上运行测试,默认范围是短暂的端口49152到65535.运行netstat显示在测试期间使用了大约5-6k端口,所以理论上应该有更多可用的端口.如果缺少端口确实是异常的原因,则意味着netstat没有正确报告情况,或者HttClient仅使用最大数量的端口,之后它开始抛出异常.

相比之下,生成HTTP调用的多线程方法表现得非常可预测.对于10k消息,我花费大约0.6秒,对于100k消息大约需要5.5秒,对于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);
    }
}
Run Code Online (Sandbox Code Playgroud)

如果HttpClient包含一种限制并发请求数量的机制,那将非常有用.使用Task类(基于.Net线程池)时,通过限制并发线程数自动实现限制.

为了完整的概述,我还创建了一个基于HttpWebRequest而不是HttpClient的异步测试版本,并设法获得更好的结果.首先,它允许设置对并发连接数的限制(使用ServicePointManager.DefaultConnectionLimit或通过配置),这意味着它永远不会用完端口并且永远不会在任何请求上失败(默认情况下,HttpClient基于HttpWebRequest) ,但它似乎忽略了连接限制设置).

异步HttpWebRequest方法仍然比多线程方法慢约50-60%,但它是可预测和可靠的.它唯一的缺点是它在大负载下使用了大量内存.例如,发送100万个请求需要大约1.6 GB.通过限制并发请求的数量(就像我上面为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.

因此,虽然组合ServicePointManager.DefaultConnectionLimit的HttpClient似乎可以确保可靠性(至少对于向同一主机进行所有调用的情况),但仍然看起来它的性能受到缺乏适当的限制机制的负面影响.将可请求的并发数量限制为可配置值并将其余部分放入队列的东西将使其更适合高可伸缩性方案.

Flo*_*scu 64

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

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

因此,异步HttpClient不是密集负载应用程序的瓶颈.仅就其本质而言,它不太适合非常快速的HTTP请求,而是非常适合长期或可能很长的HTTP请求,尤其是在仅具有有限数量线程的应用程序中.此外,优良做法是通过ServicePointManager.DefaultConnectionLimit限制并发,其值足够高以确保良好的并行度,但足够低以防止短暂的端口耗尽.您可以在此处找到有关此问题的测试和结论的更多详细信息.

  • "非常快"的速度有多快?1ms的?100ms的?1,000ms? (3认同)

Dar*_*ler 27

要考虑的一件事可能是影响您的结果是使用HttpWebRequest,您没有获得ResponseStream并使用该流.使用HttpClient,默认情况下它会将网络流复制到内存流中.要以与当前使用HttpWebRquest相同的方式使用HttpClient,您需要这样做

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

另一件事是,我不确定真正的区别,从线程角度来看,你实际上是在测试.如果深入了解HttpClientHandler,它只需执行Task.Factory.StartNew即可执行异步请求.线程行为被委托给同步上下文,其方式与完成HttpWebRequest示例的示例完全相同.

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


Dav*_*ack 17

虽然这并没有直接回答OP问题的"异步"部分,但这解决了他正在使用的实现中的错误.

如果您希望应用程序扩展,请避免使用基于实例的HttpClients.差别很大!根据负载,您将看到非常不同的性能数字.HttpClient旨在跨请求重用.这是由BCL团队的人员证实的.

我最近的一个项目是帮助一家知名的大型在线计算机零售商扩展一些新系统的黑色星期五/假日流量.我们遇到了一些关于HttpClient使用的性能问题.自实现以来IDisposable,开发人员通过创建实例并将其放在using()语句中来完成您通常所做的操作.一旦我们开始加载测试,应用程序就会让服务器瘫痪 - 是的,服务器不仅仅是应用程序.原因是HttpClient的每个实例都在服务器上打开一个I/O完成端口.由于GC的非确定性最终确定以及您使用跨越多个OSI层的计算机资源这一事实,关闭网络端口可能需要一段时间.事实上,Windows操作系统本身最多可能需要20秒才能关闭端口(每个微软).我们打开端口比关闭它们更快 - 服务器端口耗尽,这使CPU达到100%.我的修复是将HttpClient更改为一个解决问题的静态实例.是的,它是一种可支配的资源,但任何开销都远远超过性能差异.我鼓励您进行一些负载测试,以了解您的应用的行为方式.

也在以下链接回答:

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

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