关于c#:.Net 4.5的异步HttpClient是密集加载应用程序的不良选择吗?

Is async HttpClient from .Net 4.5 a bad choice for intensive load applications?

我最近创建了一个简单的应用程序来测试HTTP调用吞吐量,与传统的多线程方法相比,它可以异步生成。

应用程序能够执行预定义数量的HTTP调用,最后显示执行这些调用所需的总时间。在我的测试期间,所有HTTP调用都被发送到我的本地IIS服务器,它们检索到一个小的文本文件(12字节大小)。

下面列出了异步实现代码的最重要部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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();
        }
    }
}

多线程实现的最重要部分如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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();
    }
}

运行测试表明多线程版本更快。完成10公里请求大约需要0.6秒,而异步请求则需要2秒完成相同的负载量。这有点令人惊讶,因为我希望异步的速度更快。可能是因为我的HTTP调用非常快。在真实的场景中,服务器应该执行更有意义的操作,并且应该有一些网络延迟,结果可能会颠倒。

然而,真正让我担心的是,当负载增加时,httpclient的行为方式。由于发送10万条消息需要2秒左右,我认为发送10倍的消息需要20秒左右,但运行测试表明,发送10万条消息需要50秒左右。此外,通常需要2分钟以上的时间来传递20万条消息,并且通常会有数千条(3-4K)由于以下异常而失败:

An operation on a socket could not be performed because the system lacked sufficient buffer space or because a queue was full.

我检查了失败的IIS日志和操作,但没有到达服务器。他们在客户机中失败了。我在Windows7机器上运行了测试,默认的临时端口范围是49152到65535。运行netstat表明,测试期间使用了大约5-6K端口,因此理论上应该有更多可用的端口。如果缺少端口确实是导致异常的原因,那么这意味着netstat没有正确地报告这种情况,或者httclient只使用了最多数量的端口,在这些端口之后,它开始抛出异常。

相比之下,生成HTTP调用的多线程方法的行为非常可预测。我用了大约0.6秒发了10万条信息,用了大约5.5秒发了10万条信息,正如预期的那样,用了大约55秒发了100万条信息。没有一条消息失败。此外,在运行时,它从未使用超过55MB的RAM(根据Windows任务管理器)。异步发送消息时使用的内存随负载成比例增长。在200k条消息测试中,它使用了大约500MB的RAM。

我认为上述结果有两个主要原因。第一个问题是,httpclient在创建与服务器的新连接时似乎非常贪婪。netstat报告的大量已用端口意味着它可能不会从http keep-alive中受益太多。

第二个问题是httpclient似乎没有节流机制。实际上,这似乎是与异步操作相关的一般问题。如果您需要执行大量的操作,那么它们都将立即启动,然后在它们可用时执行它们的延续。在理论上,这应该是可以的,因为在异步操作中,负载是在外部系统上的,但正如上面所证明的,情况并非完全如此。一次启动大量请求将增加内存使用率并降低整个执行速度。

通过使用简单但原始的延迟机制限制异步请求的最大数量,我成功地获得了更好的结果、内存和执行时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
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包含一种限制并发请求数量的机制,这将非常有用。使用任务类(基于.NET线程池)时,通过限制并发线程的数量自动实现节流。

为了获得完整的概述,我还创建了一个基于httpwebrequest而不是httpclient的异步测试版本,并设法获得更好的结果。首先,它允许对并发连接的数量设置限制(使用servicepointmanager.default connection limit或通过config),这意味着它从未耗尽端口,也从未在任何请求上失败(默认情况下,httpclient基于httpwebrequest,但它似乎忽略了连接限制设置)。

异步httpwebrequest方法仍然比多线程方法慢50-60%,但它是可预测和可靠的。唯一的缺点是它在大负载下使用了大量的内存。例如,它需要大约1.6GB来发送100万个请求。通过限制并发请求的数量(就像我在上面为httpclient所做的那样),我设法将使用的内存减少到j


除了这个问题中提到的测试之外,我最近还创建了一些新的测试,其中涉及的HTTP调用要少得多(5000次比100万次少),但是对于执行时间要长得多的请求(500毫秒比1毫秒多)。两个测试仪应用程序,同步多线程应用程序(基于httpwebrequest)和异步I/O应用程序(基于http客户端)都产生了类似的结果:使用大约3%的CPU和30 MB的内存执行大约10秒。两个测试仪之间唯一的区别是多线程测试仪使用310个线程来执行,而异步测试仪只有22个线程。因此,在一个将I/O绑定操作和CPU绑定操作结合在一起的应用程序中,异步版本会产生更好的结果,因为执行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限制并发性是一个很好的实践,其值足够高以确保良好的并行性,但足够低以防止短暂的端口耗尽。您可以在这里找到关于这个问题的测试和结论的更多详细信息。


要考虑的一件事可能会影响您的结果是,使用httpwebrequest,您不会得到responseStream并使用该流。对于httpclient,默认情况下,它会将网络流复制到内存流中。为了以与当前使用httpwebrquest相同的方式使用httpclient,需要执行以下操作

1
2
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和所有强类型头。它本身不是性能优化。


虽然这并不能直接回答操作问题的"异步"部分,但这解决了他正在使用的实现中的一个错误。

如果希望应用程序能够扩展,请避免使用基于实例的httpclients。差别很大!根据负载的不同,您将看到非常不同的性能数字。httpclient被设计为跨请求重用。这一点得到了BCL团队的成员们的确认。

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

也在下面的链接中回答:

在WebAPI客户机中,每次调用创建新的httpclient的开销是多少?

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