使用 HttpClient 的并发请求花费的时间比预期的要长

Concurrent requests with HttpClient take longer than expected

我有一个同时接收多个请求的网络服务。对于每个请求,我需要调用另一个 web 服务(身份验证的东西)。问题是,如果同时发生多个 (>20) 个请求,响应时间会突然变得更糟。

我做了一个例子来演示这个问题:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;

namespace CallTest
{
    public class Program
    {
        private static readonly HttpClient _httpClient = new HttpClient(new HttpClientHandler { Proxy = null, UseProxy = false });

        static void Main(string[] args)
        {
            ServicePointManager.DefaultConnectionLimit = 100;
            ServicePointManager.Expect100Continue = false;

            // warmup
            CallSomeWebsite().GetAwaiter().GetResult();
            CallSomeWebsite().GetAwaiter().GetResult();

            RunSequentiell().GetAwaiter().GetResult();

            RunParallel().GetAwaiter().GetResult();
        }

        private static async Task RunParallel()
        {
            var tasks = new List<Task>();
            for (var i = 0; i < 300; i++)
            {
                tasks.Add(CallSomeWebsite());
            }
            await Task.WhenAll(tasks);
        }

        private static async Task RunSequentiell()
        {
            var tasks = new List<Task>();
            for (var i = 0; i < 300; i++)
            {
                await CallSomeWebsite();
            }
        }

        private static async Task CallSomeWebsite()
        {
            var watch = Stopwatch.StartNew();
            using (var result = await _httpClient.GetAsync("http://example.com").ConfigureAwait(false))
            {
                // more work here, like checking success etc.
                Console.WriteLine(watch.ElapsedMilliseconds);
            }
        }
    }
}

顺序调用没问题。它们需要几毫秒才能完成,响应时间基本相同。

但是,并行请求 开始花费的时间越来越长 发送的请求越多。有时甚至需要几秒钟。我在 .NET Framework 4.6.1 和 .NET Core 2.0 上对其进行了测试,结果相同。

更奇怪的是:我用 WireShark 跟踪了 HTTP 请求,它们总是花费大约相同的时间。 但是示例程序报告的并行请求值比 WireShark 高得多。

如何为并行请求获得相同的性能?这是线程池问题吗?

您遇到问题的原因是 .NET 不会按照等待它们的顺序恢复 Tasks,等待的 Task 仅在调用函数无法恢复时恢复恢复执行,Task 不用于 Parallel 执行。

如果您进行一些修改,以便将 i 传递给 CallSomeWebsite 函数并在将所有任务添加到列表后调用 Console.WriteLine("All loaded");,您将得到一些东西像这样:(RequestNumber: Time)

All loaded
0: 164
199: 236
299: 312
12: 813
1: 837
9: 870
15: 888
17: 905
5: 912
10: 952
13: 952
16: 961
18: 976
19: 993
3: 1061
2: 1061

您是否注意到每个 Task 是如何在任何时间打印到屏幕之前创建的?创建 Tasks 的整个循环在任何 Tasks 在等待网络调用后恢复执行之前完成。

另外,看看请求199是如何在请求1之前完成的? .NET 将按照它认为最好的顺序恢复 Tasks(这肯定会更复杂,但我不确定 .NET 如何决定哪个 Task 继续) .

我认为您可能会混淆的一件事是 AsynchronousParallel。它们不一样,Task用于Asynchronous执行。这意味着所有这些任务都在同一个线程上 运行(可能。如果需要,.NET 可以为任务启动一个新线程),所以它们不是 运行 在 Parallel。如果他们真的Parallel,他们将在不同的线程中都是运行,并且每次执行的执行时间不会增加。

更新的功能:

    private static async Task RunParallel()
    {
        var tasks = new List<Task>();
        for (var i = 0; i < 300; i++)
        {
            tasks.Add(CallSomeWebsite(i));
        }
        Console.WriteLine("All loaded");
        await Task.WhenAll(tasks);
    }

    private static async Task CallSomeWebsite(int i)
    {
        var watch = Stopwatch.StartNew();
        using (var result = await _httpClient.GetAsync("https://www.google.com").ConfigureAwait(false))
        {
            // more work here, like checking success etc.
            Console.WriteLine($"{i}: {watch.ElapsedMilliseconds}");
        }
    }

至于 Asynchronous 执行打印的时间比 Synchronous 执行打印的时间长的原因,您当前的跟踪时间方法没有考虑执行之间花费的时间停止和继续。这就是为什么所有报告执行时间都在增加已完成请求集的原因。如果你想要一个准确的时间,你需要找到一种方法来减去 await 发生和执行继续之间所花费的时间。问题不在于花费的时间更长,而是您的报告方法不准确。如果将所有 Synchronous 调用的时间加起来,它实际上明显多于 Asynchronous 调用的最大时间:

Sync: 27965
Max Async: 2341

在问题的 RunParallel() 函数中,在程序 运行ning 的第一秒内为所有 300 个调用启动秒表,并在每个 http 请求完成时结束。

因此这些时间不能真正与顺序迭代进行比较。

对于较少数量的并行任务,例如50,如果您测量顺序和并行方法所花费的时间,您应该发现 并行方法更快 因为它流水线化了尽可能多的 GetAsync 任务。

就是说,当 运行 对代码进行 300 次迭代时,当 运行 仅在调试器之外时,我确实发现了一个可重复的几秒停顿:

Debug build, in debugger: Sequential 27.6 seconds, parallel 0.6 seconds

Debug build, without debugger: Sequential 26.8 seconds, parallel 3.2 seconds

[编辑]

描述了一个类似的场景 in this question,它可能与您的问题无关。

任务越多,这个问题就越严重 运行,并且在以下情况下消失:

  • 交换 GetAsync 工作以获得等效延迟
  • 运行 针对本地服务器
  • 降低任务​​创建速度/运行减少并发任务

所有连接的 watch.ElapsedMilliseconds 诊断停止,表明所有连接都受到限制的影响。

似乎是主机或网络中的某种(反 syn-flood?)节流,一旦一定数量的套接字开始连接就会停止数据包流。

听起来无论出于何种原因,您都在减少 returns 大约 20 个并发任务。因此,您最好的选择可能是限制并行度。 TPL Dataflow 是实现此目标的绝佳库。要遵循您的模式,请添加如下方法:

private static Task RunParallelThrottled()
{
    var throtter = new ActionBlock<int>(i => CallSomeWebsite(),
        new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 20 });

    for (var i = 0; i < 300; i++)
    {
        throttler.Post(i);
    }
    throttler.Complete();
    return throttler.Completion;
}

您可能需要尝试 MaxDegreeOfParallelism,直到找到最佳点。请注意,这比批量处理 20 个更有效。在那种情况下,该批次中的所有 20 个都需要在下一个批次开始之前完成。使用 TPL 数据流,一个完成后,另一个就可以开始。

此行为已在 .NET Core 2.1 中修复。我认为问题出在 HttpClient 使用的底层 windows WinHTTP 处理程序。

在 .NET Core 2.1 中,他们重写了 HttpClientHandler(参见 https://blogs.msdn.microsoft.com/dotnet/2018/04/18/performance-improvements-in-net-core-2-1/#user-content-networking):

In .NET Core 2.1, HttpClientHandler has a new default implementation implemented from scratch entirely in C# on top of the other System.Net libraries, e.g. System.Net.Sockets, System.Net.Security, etc. Not only does this address the aforementioned behavioral issues, it provides a significant boost in performance (the implementation is also exposed publicly as SocketsHttpHandler, which can be used directly instead of via HttpClientHandler in order to configure SocketsHttpHandler-specific properties).

事实证明,这消除了问题中提到的瓶颈。

在 .NET Core 2.0 上,我得到以下数字(以毫秒为单位):

Fetching URL 500 times...
Sequentiell   Total: 4209, Max:  35, Min: 6, Avg:  8.418
Parallel      Total:  822, Max: 338, Min: 7, Avg: 69.126

但在 .NET Core 2.1 上,单个并行 HTTP 请求似乎有了很大改进:

Fetching URL 500 times...
Sequentiell   Total: 4020, Max:  40, Min: 6, Avg:  8.040
Parallel      Total:  795, Max:  76, Min: 5, Avg:  7.972