【问题标题】:Concurrent requests with HttpClient take longer than expected使用 HttpClient 的并发请求花费的时间比预期的要长
【发布时间】:2018-10-03 19:13:08
【问题描述】:

我有一个同时接收多个请求的网络服务。对于每个请求,我都需要调用另一个 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 高得多。

我怎样才能获得相同的并行请求性能?这是线程池问题吗?

【问题讨论】:

  • 澄清一下,DefaultConnectionLimit 设置为 100,您仍然看到 25 个并发请求的速度变慢了吗?你确定服务器没有限制你吗?
  • @StephenCleary 是的,问题仍然存在。服务器没有限制我,WireShark 报告所有请求完成的时间比我们在控制台输出中看到的要短。

标签: c# dotnet-httpclient


【解决方案1】:

此行为已在 .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):

在 .NET Core 2.1 中,HttpClientHandler 有一个新的默认实现,完全在 C# 中在其他 System.Net 库之上实现,例如System.Net.Sockets、System.Net.Security 等。这不仅解决了上述行为问题,而且显着提高了性能(实现也公开公开为 SocketsHttpHandler,可以直接使用,而不是通过 HttpClientHandler为了配置 SocketsHttpHandler 特定的属性)。

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

在 .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

【讨论】:

    【解决方案2】:

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

    因此,这些时间无法真正与顺序迭代进行比较。

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

    也就是说,在运行代码 300 次迭代时,我确实发现仅在调试器之外运行时会出现可重复的几秒钟的停顿:

    调试构建,在调试器中:连续 27.6 秒,并行 0.6 秒

    调试构建,没有调试器:连续 26.8 秒,并行 3.2 秒

    [编辑]

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

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

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

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

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

    【讨论】:

      【解决方案3】:

      听起来无论出于何种原因,您在大约 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 数据流,一旦完成,就允许另一个开始。

      【讨论】:

        【解决方案4】:

        您遇到问题的原因是.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
        

        【讨论】:

        • 我认为你没有回答我的问题。当然,“全部加载”会在任何任务完成之前打印出来。而且我知道任务完成顺序有些随机。这仍然不能解释为什么响应时间要长得多。此外,似乎任务可以在不同的线程上运行:stackoverflow.com/q/33821679/2829009
        • @Manu 查看我的编辑。我以为你在问别的。至于在不同线程上运行的Tasks,我说这是我的回答中的一种可能性,但在这种情况下可能不会发生。
        • 哦,我现在明白了。我的计时方法有些缺陷。唯一的问题是HttpClientTimeout 属性似乎以类似的方式测量时间......
        • "如果它们是真正的并行,它们都将在不同的线程中运行,并且每次执行的执行时间不会增加。"这是不准确的。这些是 I/O 绑定任务,强制混合 300 个线程不会加速其中任何一个。我预计在这种情况下会出现大致相同的结果,或者更糟。如果 OP 希望所有 300 次调用同时发生,那么他已经用Task.WhenAll 正确地做到了。
        猜你喜欢
        • 1970-01-01
        • 2016-05-22
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2018-10-30
        • 1970-01-01
        • 2020-04-01
        相关资源
        最近更新 更多