使用 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
是如何在任何时间打印到屏幕之前创建的?创建 Task
s 的整个循环在任何 Task
s 在等待网络调用后恢复执行之前完成。
另外,看看请求199是如何在请求1之前完成的? .NET
将按照它认为最好的顺序恢复 Task
s(这肯定会更复杂,但我不确定 .NET
如何决定哪个 Task
继续) .
我认为您可能会混淆的一件事是 Asynchronous
和 Parallel
。它们不一样,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
我有一个同时接收多个请求的网络服务。对于每个请求,我需要调用另一个 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
是如何在任何时间打印到屏幕之前创建的?创建 Task
s 的整个循环在任何 Task
s 在等待网络调用后恢复执行之前完成。
另外,看看请求199是如何在请求1之前完成的? .NET
将按照它认为最好的顺序恢复 Task
s(这肯定会更复杂,但我不确定 .NET
如何决定哪个 Task
继续) .
我认为您可能会混淆的一件事是 Asynchronous
和 Parallel
。它们不一样,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