Почему отмена блокируется так долго при отмене большого количества HTTP-запросов?

Фон

У меня есть код, который выполняет пакетную обработку HTML-страницы с использованием контента с одного конкретного хоста. Он пытается сделать большое количество (~ 400) одновременных HTTP-запросов, используя HttpClient. Я считаю, что максимальное количество одновременных подключений ограничено ServicePointManager.DefaultConnectionLimit, поэтому я не применяю свои собственные ограничения параллелизма.

После асинхронной отправки всех запросов на HttpClient с помощью Task.WhenAll вся пакетная операция может быть отменена с помощью CancellationTokenSource и CancellationToken. Ход операции можно просмотреть через пользовательский интерфейс, и можно нажать кнопку, чтобы выполнить отмену.

Проблема

Вызов CancellationTokenSource.Cancel() блокируется примерно на 5–30 секунд. Это приводит к зависанию пользовательского интерфейса. Есть подозрение, что это происходит из-за того, что метод вызывает код, зарегистрированный для уведомления об отмене.

Что я рассмотрел

  1. Ограничение количества одновременных задач HTTP-запросов. Я считаю это обходным путем, потому что HttpClient уже сам ставит в очередь лишние запросы.
  2. Выполнение вызова метода CancellationTokenSource.Cancel() в потоке, отличном от пользовательского интерфейса. Это не сработало; задача фактически не выполнялась, пока не завершилось большинство других. Я думаю, что async версия метода подойдет, но я не смог ее найти. Кроме того, у меня сложилось впечатление, что метод подходит для использования в потоке пользовательского интерфейса.

Демонстрация

Код

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();
    }
}

Выход

Окно консоли, показывающее, что отмена заблокирована более 13 секунд

Почему отмена блокируется так долго? Кроме того, есть ли что-то, что я делаю неправильно или можно было бы сделать лучше?


person Sam    schedule 17.02.2013    source источник


Ответы (1)


Выполнение вызова метода CancellationTokenSource.Cancel() в потоке, отличном от пользовательского интерфейса. Это не сработало; задача фактически не выполнялась, пока не завершилось большинство других.

Это говорит мне о том, что вы, вероятно, страдаете от «исчерпания пула потоков», когда в вашей очереди пула потоков так много элементов (от завершения HTTP-запросов), что требуется некоторое время, чтобы пройти через них все. Отмена, вероятно, блокирует выполнение какого-либо рабочего элемента пула потоков и не может перейти к началу очереди.

Это говорит о том, что вам нужно выбрать вариант 1 из списка возможных вариантов. Регулируйте свою работу так, чтобы очередь пула потоков оставалась относительно короткой. В любом случае, это хорошо для отзывчивости приложения в целом.

Мой любимый способ регулировать асинхронную работу — использовать поток данных. Что-то вроде этого:

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, чтобы ваша задача получила новый поток (не связанный с пулом потоков), который позволил бы ей запуститься немедленно и вызвать Cancel оттуда. Но вместо этого вам, вероятно, следует решить проблему исчерпания пула потоков.

person Andrew Arnott    schedule 17.02.2013
comment
Есть ли способ избежать создания дополнительных потоков для HTTP-запросов? Я помню, как читал, что использование await с GetAsync не приведет к созданию нового потока. - person Sam; 18.02.2013
comment
Дело не в том, что он создает новые темы. Просто когда приходит ответ HTTP, поток пула потоков занят для обработки этого ответа. Пул потоков имеет определенное количество потоков (обычно 4 на четырехъядерном процессоре) при типичных рабочих нагрузках. Таким образом, если у вас есть сотни веб-запросов, у вас будет четыре или более активных потока, получающих ответы после их поступления, а остальные ответы ожидают в очереди пула потоков, вызывая отставание, которое вы видите. - person Andrew Arnott; 20.02.2013
comment
@ Сэм, какое решение ты использовал? в настоящее время я сталкиваюсь с той же проблемой - person John; 17.08.2013
comment
@Gui, я думаю, что экспериментировал с предложением в этом ответе, но, в конце концов, я не помню, чтобы реализовал что-то, чем я был доволен. Проект, над которым я работал, был просто личным проектом, и из-за внешних факторов я прекратил над ним работать. - person Sam; 18.08.2013