Является ли async HttpClient из .Net 4.5 плохим выбором для приложений с интенсивной нагрузкой?

Недавно я создал простое приложение для тестирования пропускной способности HTTP-запроса, которое может быть сгенерировано асинхронным способом против классического многопоточного подхода.

Приложение может выполнять предопределенное количество HTTP-вызовов, а в конце отображает общее время, необходимое для их выполнения. Во время моих тестов все HTTP-вызовы были сделаны для моего локального IIS-сервера, и они получили небольшой текстовый файл (размером 12 байт).

Самая важная часть кода для асинхронной реализации приведена ниже:

public async void TestAsync()
{
    this.TestInit();
    HttpClient httpClient = new HttpClient();

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        ProcessUrlAsync(httpClient);
    }
}

private async void ProcessUrlAsync(HttpClient httpClient)
{
    HttpResponseMessage httpResponse = null;

    try
    {
        Task<HttpResponseMessage> getTask = httpClient.GetAsync(URL);
        httpResponse = await getTask;

        Interlocked.Increment(ref _successfulCalls);
    }
    catch (Exception ex)
    {
        Interlocked.Increment(ref _failedCalls);
    }
    finally
    { 
        if(httpResponse != null) httpResponse.Dispose();
    }

    lock (_syncLock)
    {
        _itemsLeft--;
        if (_itemsLeft == 0)
        {
            _utcEndTime = DateTime.UtcNow;
            this.DisplayTestResults();
        }
    }
}

Наиболее важная часть реализации многопоточности приведена ниже:

public void TestParallel2()
{
    this.TestInit();
    ServicePointManager.DefaultConnectionLimit = 100;

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        Task.Run(() =>
        {
            try
            {
                this.PerformWebRequestGet();
                Interlocked.Increment(ref _successfulCalls);
            }
            catch (Exception ex)
            {
                Interlocked.Increment(ref _failedCalls);
            }

            lock (_syncLock)
            {
                _itemsLeft--;
                if (_itemsLeft == 0)
                {
                    _utcEndTime = DateTime.UtcNow;
                    this.DisplayTestResults();
                }
            }
        });
    }
}

private void PerformWebRequestGet()
{ 
    HttpWebRequest request = null;
    HttpWebResponse response = null;

    try
    {
        request = (HttpWebRequest)WebRequest.Create(URL);
        request.Method = "GET";
        request.KeepAlive = true;
        response = (HttpWebResponse)request.GetResponse();
    }
    finally
    {
        if (response != null) response.Close();
    }
}

Запуск тестов показал, что многопоточная версия была быстрее. Потребовалось около 0,6 секунды для запросов на 10 тыс., А асинхронный - около 2 секунд, чтобы выполнить ту же нагрузку. Это было немного неожиданностью, потому что я ожидал, что асинхронный будет быстрее. Возможно, это было из-за того, что мои HTTP-вызовы были очень быстрыми. В сценарии реального мира, где сервер должен выполнять более значимую операцию и где также должна быть какая-то задержка в сети, результаты могут быть отменены.

Однако меня действительно беспокоит то, как HttpClient ведет себя при увеличении нагрузки. Поскольку для доставки 10 тыс. Сообщений требуется около 2 секунд, я думал, что потребуется около 20 секунд, чтобы доставить в 10 раз больше сообщений, но запуск теста показал, что для доставки сообщений 100 тыс. Требуется около 50 секунд. Кроме того, для доставки сообщений на 200 тыс. Обычно требуется более 2 минут, а несколько тысяч из них (3-4k) терпят неудачу со следующим исключением:

Операция сокета не может быть выполнена, поскольку в системе недостаточно места для буфера или потому, что очередь заполнена.

Я проверил журналы и операции IIS, которые никогда не попадали на сервер. Они потерпели неудачу в клиенте. Я запускал тесты на машине под управлением Windows 7 с диапазоном эфемерных портов по умолчанию от 49152 до 65535 по умолчанию. Запуск netstat показал, что во время тестов использовалось около 5-6 тыс. Портов, поэтому теоретически должно было быть доступно еще много. Если отсутствие портов действительно является причиной исключений, это означает, что либо netstat не сообщал об этом должным образом, либо HttClient использует только максимальное количество портов, после которых он начинает бросать исключения.

Напротив, многопоточный подход к генерации HTTP-вызовов вел себя очень предсказуемо. Я потратил около 0,6 секунды на 10 тыс. Сообщений, около 5,5 секунд на 100 тыс. Сообщений и, как ожидается, около 55 секунд на 1 миллион сообщений. Ни одно из сообщений не было выполнено. Более того, пока он работал, он никогда не использовал более 55 МБ ОЗУ (согласно диспетчеру задач Windows). Память, используемая при отправке сообщений, асинхронно увеличивалась пропорционально нагрузке. Он использовал около 500 МБ ОЗУ во время тестов 200 тыс. Сообщений.

Я думаю, что есть две основные причины для вышеупомянутых результатов. Первый заключается в том, что HttpClient кажется очень жадным при создании новых соединений с сервером. Большое количество используемых портов, о которых сообщает netstat, означает, что он, вероятно, мало выгоден от поддержки HTTP.

Во-вторых, HttpClient, похоже, не имеет механизма дросселирования. На самом деле это, по-видимому, общая проблема, связанная с асинхронными операциями. Если вам нужно выполнить очень большое количество операций, все они будут запущены сразу, а затем их продолжения будут выполнены, поскольку они доступны. Теоретически это должно быть хорошо, потому что в асинхронных операциях нагрузка находится на внешних системах, но, как доказано выше, это не совсем так. Наличие большого количества запросов, начатых сразу, увеличит использование памяти и замедлит все выполнение.

Мне удалось получить лучшие результаты, память и время выполнения, уменьшив максимальное количество асинхронных запросов с помощью простого, но примитивного механизма задержки:

public async void TestAsyncWithDelay()
{
    this.TestInit();
    HttpClient httpClient = new HttpClient();

    for (int i = 0; i < NUMBER_OF_REQUESTS; i++)
    {
        if (_activeRequestsCount >= MAX_CONCURENT_REQUESTS)
            await Task.Delay(DELAY_TIME);

        ProcessUrlAsyncWithReqCount(httpClient);
    }
}

Было бы очень полезно, если бы HttpClient включил механизм ограничения количества одновременных запросов. При использовании класса Task (который основан на пуле потоков .Net) регулирование автоматически достигается путем ограничения количества одновременных потоков.

Для полного обзора я также создал версию асинхронного теста на основе HttpWebRequest, а не HttpClient, и мне удалось получить гораздо лучшие результаты. Для начала он позволяет установить ограничение на количество одновременных подключений (с ServicePointManager.DefaultConnectionLimit или через конфигурацию), что означает, что он никогда не заканчивался из портов и никогда не прерывался при любом запросе (по умолчанию HttpClient основан на HttpWebRequest, но он, кажется, игнорирует настройку ограничения соединения).

Асинхронный подход HttpWebRequest все еще был примерно на 50-60% медленнее, чем многопоточный, но он был предсказуемым и надежным. Единственным недостатком этого было то, что он пользовался огромным объемом памяти под большой нагрузкой. Например, для отправки 1 миллиона запросов потребовалось около 1,6 ГБ. Ограничивая количество одновременных запросов (как, например, я сделал выше для HttpClient), мне удалось сократить используемую память до 20 МБ и получить время выполнения всего на 10% медленнее, чем многопоточный подход.

После этой длинной презентации мои вопросы: Является ли класс HttpClient из .Net 4.5 плохим выбором для приложений с интенсивной загрузкой? Есть ли способ дросселировать его, что должно решить проблемы, о которых я упоминаю? Как насчет асинхронного вкуса HttpWebRequest?

Обновление (спасибо @Stephen Cleary)

Как оказалось, HttpClient, как и HttpWebRequest (по которому он основан по умолчанию), может иметь количество одновременных подключений на одном и том же узле, ограниченном ServicePointManager.DefaultConnectionLimit. Странно то, что согласно MSDN значение по умолчанию для ограничения соединения равно 2. Я также проверил, что на моей стороне, используя отладчик который указал, что действительно 2 является значением по умолчанию. Однако кажется, что если явно не установить значение ServicePointManager.DefaultConnectionLimit, значение по умолчанию будет проигнорировано. Поскольку я не задал явное значение для него во время тестов HttpClient, я думал, что это игнорируется.

После установки ServicePointManager.DefaultConnectionLimit на 100 HttpClient стал надежным и предсказуемым (netstat подтверждает, что используется только 100 портов). Он все еще медленнее, чем async HttpWebRequest (примерно на 40%), но, как ни странно, он использует меньше памяти. Для теста, который включает 1 миллион запросов, он использовал максимум 550 МБ, по сравнению с 1,6 ГБ в асинхронном HttpWebRequest.

Итак, хотя HttpClient в сочетании ServicePointManager.DefaultConnectionLimit, похоже, обеспечивает надежность (по крайней мере, для сценария, где все вызовы принимаются к одному и тому же хосту), по-прежнему выглядит, что на его производительность отрицательно влияет отсутствие надлежащего механизм дросселирования. Что-то, что ограничивало бы одновременное количество запросов на настраиваемое значение и остальное в очереди, сделало бы его гораздо более подходящим для сценариев с высокой масштабируемостью.

Ответ 1

Помимо тестов, упомянутых в этом вопросе, я недавно создал несколько новых приложений с гораздо меньшим количеством HTTP-вызовов (5000 по сравнению с 1 миллионом ранее), но по запросам, которые потребовались намного дольше (500 миллисекунд по сравнению с примерно 1 миллисекундой ранее). Оба приложения-тестеры, синхронно многопоточные (на основе HttpWebRequest) и асинхронного ввода-вывода (на основе HTTP-клиента) дали аналогичные результаты: около 10 секунд для выполнения с использованием около 3% процессора и 30 МБ памяти. Единственная разница между двумя тестерами заключалась в том, что многопоточность использовала 310 потоков для выполнения, тогда как асинхронная - просто 22. Таким образом, в приложении, которое объединило бы операции привязки с привязкой к вводу-выводу и процессором, асинхронная версия принесла бы лучшие результаты потому что было бы больше времени процессора для потоков, выполняющих операции ЦП, которые им действительно нужны (потоки, ожидающие завершения операций ввода-вывода, просто теряют смысл).

Как вывод моих тестов, асинхронные HTTP-вызовы не являются наилучшим вариантом при работе с очень быстрыми запросами. Причина этого в том, что при запуске задачи, содержащей асинхронный вызов ввода-вывода, поток, на котором запускается задача, завершается, как только выполняется асинхронный вызов, а остальная часть задачи регистрируется как обратный вызов. Затем, когда операция ввода-вывода завершается, обратный вызов ставится в очередь для выполнения в первом доступном потоке. Все это создает накладные расходы, что делает быстрые операции ввода-вывода более эффективными при выполнении в потоке, который их запускал.

Асинхронные HTTP-вызовы являются хорошим вариантом при работе с длинными или потенциально длинными операциями ввода-вывода, потому что он не удерживает нити в ожидании завершения операций ввода-вывода. Это уменьшает общее количество потоков, используемых приложением, позволяя затрачивать больше процессорного времени на операции, связанные с ЦП. Кроме того, в приложениях, которые выделяют только ограниченное количество потоков (например, в случае с веб-приложениями), асинхронный ввод-вывод предотвращает истощение потока пула потоков, что может случиться при синхронном выполнении вызовов ввода/вывода.

Итак, async HttpClient не является узким местом для приложений с интенсивной нагрузкой. Просто по своей природе он не очень хорошо подходит для очень быстрых HTTP-запросов, вместо этого он идеален для длинных или потенциально длинных, особенно внутри приложений, которые имеют ограниченное количество потоков. Кроме того, рекомендуется ограничивать concurrency через ServicePointManager.DefaultConnectionLimit со значением, достаточно высоким, чтобы обеспечить хороший уровень parallelism, но достаточно низким, чтобы предотвратить истощение эфемерного порта. Вы можете найти более подробную информацию о тестах и ​​выводах, представленных для этого вопроса здесь.

Ответ 2

Одна вещь, которая может повлиять на ваши результаты, заключается в том, что с HttpWebRequest вы не получаете ResponseStream и не потребляете этот поток. С помощью HttpClient по умолчанию он будет копировать сетевой поток в поток памяти. Чтобы использовать HttpClient так же, как вы сейчас используете HttpWebRquest, вам нужно будет сделать

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

Другое дело, что я не совсем уверен, какая реальная разница, с точки зрения потоков, вы на самом деле проверяете. Если вы заглядываете в глубины HttpClientHandler, он просто выполняет Task.Factory.StartNew для выполнения асинхронного запроса. Поведение потоковой передачи делегировано контексту синхронизации точно так же, как и ваш пример с примером HttpWebRequest.

Несомненно, HttpClient добавляет некоторые накладные расходы, поскольку по умолчанию он использует HttpWebRequest в качестве своей транспортной библиотеки. Таким образом, вы всегда сможете получить лучший перфект с помощью HttpWebRequest напрямую, используя HttpClientHandler. Преимущества, которые предлагает HttpClient, - это стандартные классы, такие как HttpResponseMessage, HttpRequestMessage, HttpContent и все строго типизированные заголовки. Сама по себе это не первоочередная оптимизация.

Ответ 3

В то время как это не отвечает непосредственно на "асинхронную" часть вопроса OP, это обращается к ошибке в реализации, которую он использует.

Если вы хотите, чтобы ваше приложение масштабировалось, избегайте использования HttpClients на основе экземпляров. Разница ОГРОМНАЯ! В зависимости от нагрузки вы увидите очень разные показатели производительности. HttpClient был разработан для повторного использования в разных запросах. Это подтвердили ребята из команды BCL, которые ее написали.

Недавний проект, который я имел, заключался в том, чтобы помочь очень крупному и известному онлайн-магазину компьютеров в масштабе черных для Black Friday/holiday traffic для некоторых новых систем. Мы столкнулись с некоторыми проблемами производительности, связанными с использованием HttpClient. Поскольку он реализует IDisposable, разработчики выполнили то, что вы обычно делали, создав экземпляр и разместив его внутри оператора using(). Как только мы начали тестирование нагрузки, приложение поставило сервер на колени - да, сервер не просто приложение. Причина в том, что каждый экземпляр HttpClient открывает порт завершения ввода-вывода на сервере. Из-за недетерминированной окончательной доработки GC и того факта, что вы работаете с компьютерными ресурсами, которые охватывают несколько слоев Каковы накладные расходы на создание нового HttpClient для каждого вызова в клиенте WebAPI?

https://www.asp.net/web-api/overview/advanced/calling-a-web-api-from-a-net-client