【问题标题】:Why does cancellation block for so long when cancelling a lot of HTTP requests?为什么取消大量HTTP请求时取消阻塞这么长时间?
【发布时间】:2013-02-17 12:44:52
【问题描述】:

背景

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

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

问题

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

我的考虑

  1. 限制并发 HTTP 请求任务的数量。我认为这是一种解决方法,因为 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<int, Task> 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();
    }
}

输出

为什么取消会阻止这么长时间?另外,我做错了什么或者可以做得更好吗?

【问题讨论】:

标签: c# performance .net-4.5 c#-5.0 cancellationtokensource


【解决方案1】:

在非 UI 线程中执行 CancellationTokenSource.Cancel() 方法调用。这效果不太好。直到大多数其他任务完成后,该任务才真正运行。

这告诉我的是,您可能正遭受“线程池耗尽”的困扰,这是您的线程池队列中有太多项目(来自 HTTP 请求完成)的地方,需要一段时间才能完成所有项目。取消可能会阻塞一些正在执行的线程池工作项,它不能跳到队列的头部。

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

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

var block = new ActionBlock<Uri>(
    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 以便您的任务获得一个 new 线程(不隶属于线程池),这将允许它立即启动并调用 Cancel从那里。但是你应该解决线程池耗尽问题。

【讨论】:

  • 有什么方法可以避免为 HTTP 请求创建额外的线程吗?我记得读过使用awaitGetAsync 不会导致创建新线程。
  • 并不是说它创建了新线程。只是当HTTP响应进来时,一个线程池线程被占用来处理那个响应。在典型的工作负载下,线程池只有这么多线程(通常是四核上的 4 个)。因此,如果您有数百个 Web 请求,一旦响应进入,您将有四个或更多活动线程接收响应,而其余响应在线程池队列中等待,从而导致您看到的积压。
  • @Sam 那么您使用了什么解决方案?我目前遇到同样的问题
  • @Gui,我想我尝试了这个答案中的建议,但最后我不记得实现了我满意的东西。我正在做的项目只是一个个人项目,由于外部因素,我停止了它。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多