为什么在取消大量HTTP请求时取消会阻塞这么长时间?

背景

我有一些代码使用来自一个特定主机的内容执行批量HTML页面处理。 它尝试使用HttpClient创建大量(~400)的同步HTTP请求。 我相信ServicePointManager.DefaultConnectionLimit限制了最大并发连接数,所以我没有应用自己的并发限制。

使用Task.WhenAll将所有请求异步发送到HttpClient Task.WhenAll ,可以使用CancellationTokenSourceCancellationToken取消整个批处理操作。 可以通过用户界面查看操作的进度,并且可以单击按钮以执行取消。

问题

CancellationTokenSource.Cancel()的调用会阻塞大约5到30秒。 这会导致用户界面冻结。 怀疑是因为该方法正在调用注册取消通知的代码。

我考虑过什么

  1. 限制同时HTTP请求任务的数量。 我认为这是一种解决方法,因为HttpClient似乎已经HttpClient了过多的请求本身。
  2. 在非UI线程中执行CancellationTokenSource.Cancel()方法调用。 这不太好用; 在大多数其他任务完成之前,任务实际上并未运行。 我认为该方法的async版本可以很好地工作,但我找不到一个。 另外,我的印象是它适合在UI线程中使用该方法。

示范

 class Program { private const int desiredNumberOfConnections = 418; static void Main(string[] args) { ManyHttpRequestsTest().Wait(); Console.WriteLine("Finished."); Console.ReadKey(); } private static async Task ManyHttpRequestsTest() { using (var client = new HttpClient()) using (var cancellationTokenSource = new CancellationTokenSource()) { var requestsCompleted = 0; using (var allRequestsStarted = new CountdownEvent(desiredNumberOfConnections)) { Action reportRequestStarted = () => allRequestsStarted.Signal(); Action reportRequestCompleted = () => Interlocked.Increment(ref requestsCompleted); Func getHttpResponse = index => GetHttpResponse(client, cancellationTokenSource.Token, reportRequestStarted, reportRequestCompleted); var httpRequestTasks = Enumerable.Range(0, desiredNumberOfConnections).Select(getHttpResponse); Console.WriteLine("HTTP requests batch being initiated"); var httpRequestsTask = Task.WhenAll(httpRequestTasks); Console.WriteLine("Starting {0} requests (simultaneous connection limit of {1})", desiredNumberOfConnections, ServicePointManager.DefaultConnectionLimit); allRequestsStarted.Wait(); Cancel(cancellationTokenSource); await WaitForRequestsToFinish(httpRequestsTask); } Console.WriteLine("{0} HTTP requests were completed", requestsCompleted); } } private static void Cancel(CancellationTokenSource cancellationTokenSource) { Console.Write("Cancelling..."); var stopwatch = Stopwatch.StartNew(); cancellationTokenSource.Cancel(); stopwatch.Stop(); Console.WriteLine("took {0} seconds", stopwatch.Elapsed.TotalSeconds); } private static async Task WaitForRequestsToFinish(Task httpRequestsTask) { Console.WriteLine("Waiting for HTTP requests to finish"); try { await httpRequestsTask; } catch (OperationCanceledException) { Console.WriteLine("HTTP requests were cancelled"); } } private static async Task GetHttpResponse(HttpClient client, CancellationToken cancellationToken, Action reportStarted, Action reportFinished) { var getResponse = client.GetAsync("http://www.google.com", cancellationToken); reportStarted(); using (var response = await getResponse) response.EnsureSuccessStatusCode(); reportFinished(); } } 

产量

控制台窗口显示取消被阻止超过13秒

为什么取消阻止这么久? 还有,我做错了什么或者做得更好?

在非UI线程中执行CancellationTokenSource.Cancel()方法调用。 这不太好用; 在大多数其他任务完成之前,任务实际上并未运行。

这告诉我的是,你可能正在遭受’线程池耗尽’,这是你的线程池队列中有这么多项目(来自HTTP请求完成),需要一段时间来完成它们。 取消可能阻止某些线程池工作项执行,它不能跳到队列的头部。

这表明您确实需要从您的考虑清单中选择选项1。 限制自己的工作,以便线程池队列保持相对较短。 无论如何,这对于整体应用响应非常有用。

我最喜欢的限制异步工作的方法是使用Dataflow 。 像这样的东西:

 var block = new ActionBlock( async uri => { var httpClient = new HttpClient(); // HttpClient isn't thread-safe, so protect against concurrency by using a dedicated instance for each request. var result = await httpClient.GetAsync(uri); // do more stuff with result. }, new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 20, CancellationToken = cancellationToken }); for (int i = 0; i < 1000; i++) block.Post(new Uri("http://www.server.com/req" + i)); block.Complete(); await block.Completion; // waits until everything is done or canceled. 

作为替代方案,您可以使用Task.Factory.StartNew在TaskCreationOptions.LongRunning中传递,以便您的任务获得一个线程(不隶属于线程池),这将允许它立即启动并从那里调用取消。 但你应该解决线程池耗尽问题。