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

130

Недавно я создал простое приложение для тестирования пропускной способности 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();
    }
}

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

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

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

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

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

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

Во-вторых, у 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 или через config), что означает, что он никогда не исчерпывал порты и никогда не терпел сбоев ни при одном запросе (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 портов). Он по-прежнему медленнее, чем асинхронный HttpWebRequest (примерно на 40%), но, как ни странно, использует меньше памяти. Для теста, который включает 1 миллион запросов, он использовал максимум 550 МБ по сравнению с 1,6 ГБ в асинхронном HttpWebRequest.

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

Флорин Думитреску
источник
4
HttpClientследует уважать ServicePointManager.DefaultConnectionLimit.
Стивен Клири
2
Ваши наблюдения кажутся заслуживающими изучения. Однако меня беспокоит одна вещь: я думаю, что он очень искусен для одновременного выполнения тысяч асинхронных операций ввода-вывода. Я бы никогда этого не сделал в продакшене. Тот факт, что вы асинхронный, не означает, что вы можете сходить с ума, потребляя различные ресурсы. (Официальные образцы Microsoft также немного вводят в заблуждение в этом отношении.)
usr
1
Однако не ограничивайте себя задержками во времени. Ограничьте фиксированный уровень параллелизма, который вы определяете эмпирически. Простым решением будет SemaphoreSlim.WaitAsync, хотя он также не подходит для сколь угодно большого количества задач.
usr
1
@FlorinDumitrescu Для дросселирования можно использовать SemaphoreSlim, как уже упоминалось, или ActionBlock<T>из TPL Dataflow.
svick
1
@svick, спасибо за ваши предложения. Меня не интересует ручная реализация механизма регулирования / ограничения параллелизма. Как уже упоминалось, реализация, включенная в мой вопрос, предназначалась только для тестирования и проверки теории. Я не пытаюсь его улучшить, так как в производство он не попадет. Что меня интересует, так это то, предлагает ли платформа .Net встроенный механизм для ограничения параллелизма операций асинхронного ввода-вывода (включая HttpClient).
Флорин Думитреску

Ответы:

64

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

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

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

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

Флорин Думитреску
источник
3
Насколько быстро "очень быстро"? 1мс? 100мс? 1,000ms?
Тим П.
Я использую что-то вроде вашего «асинхронного» подхода для воспроизведения нагрузки на веб-сервер WebLogic, развернутый в Windows, но у меня довольно быстро возникает проблема с эфемическим истощением портов. Я не касался ServicePointManager.DefaultConnectionLimit, и я удаляю и воссоздаю все (HttpClient и ответ) по каждому запросу. Вы хоть представляете, что может быть причиной того, что соединения остаются открытыми и истощают порты?
Иреванчи 09
@TimP. для моих тестов, как упоминалось выше, «очень быстрыми» были запросы, выполнение которых занимало всего 1 миллисекунду. В реальном мире это всегда будет субъективным. С моей точки зрения, что-то, эквивалентное небольшому запросу в базе данных локальной сети, можно считать быстрым, а что-то эквивалентное вызову API через Интернет можно считать медленным или потенциально медленным.
Флорин Думитреску
1
@Iravanchi, в "асинхронных" подходах отправка запросов и обработка ответов выполняются отдельно. Если у вас много звонков, все запросы будут отправляться очень быстро, и ответы будут обработаны, когда они поступят. Поскольку вы можете удалять соединения только после получения их ответов, большое количество одновременных соединений может накапливаться и истощать ваши эфемерные порты. Вы должны ограничить максимальное количество одновременных подключений с помощью ServicePointManager.DefaultConnectionLimit.
Флорин Думитреску
1
@FlorinDumitrescu, я бы также добавил, что сетевые вызовы по своей природе непредсказуемы. Вещи, которые выполняются в течение 10 мс 90% времени, могут вызывать проблемы с блокировкой, когда этот сетевой ресурс перегружен или недоступен в остальные 10% времени.
Тим П.
27

Одна вещь, которую следует учитывать, которая может повлиять на ваши результаты, заключается в том, что с 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 и всех строго типизированных заголовков. Само по себе это не оптимизация производительности.

Даррел Миллер
источник
(старый ответ, но) HttpClientкажется простым в использовании, и я подумал, что асинхронный - это лучший вариант, но, похоже, вокруг этого есть много «но и если». Может, HttpClientстоит переписать так, чтобы им было удобнее пользоваться? Или что документация действительно подчеркивала важные моменты о том, как ее использовать наиболее эффективно?
mortb
@mortb, Flurl.Http flurl.io - более интуитивно понятная обертка для HttpClient
Майкл Фрейдгейм
1
@MichaelFreidgeim: Спасибо, хотя я уже научился жить с HttpClient ...
Mortb
17

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

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

Недавний проект, который у меня был, заключался в том, чтобы помочь очень крупному и хорошо известному онлайн-продавцу компьютеров увеличить объем трафика в Черную пятницу / праздник для некоторых новых систем. Мы столкнулись с некоторыми проблемами производительности при использовании HttpClient. Поскольку он реализуется IDisposable, разработчики сделали то, что обычно делали бы вы, создав экземпляр и поместив его в using()оператор. Как только мы начали нагрузочное тестирование, приложение поставило сервер на колени - да, сервер, а не только приложение. Причина в том, что каждый экземпляр HttpClient открывает порт завершения ввода-вывода на сервере. Из-за недетерминированного завершения сборки мусора и того факта, что вы работаете с компьютерными ресурсами, которые охватывают несколько уровней OSI. , закрытие сетевых портов может занять некоторое время. Фактически сама ОС Windowsзакрытие порта может занять до 20 секунд (по Microsoft). Мы открывали порты быстрее, чем они могли быть закрыты - исчерпание портов сервера, из-за чего ЦП загружался на 100%. Мое исправление состояло в том, чтобы изменить HttpClient на статический экземпляр, который решил проблему. Да, это одноразовый ресурс, но любые накладные расходы значительно перевешиваются разницей в производительности. Я рекомендую вам провести нагрузочное тестирование, чтобы увидеть, как ведет себя ваше приложение.

Также ответил по ссылке ниже:

Каковы затраты на создание нового HttpClient для каждого вызова в клиенте WebAPI?

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

Дэйв Блэк
источник
Я обнаружил точно такую ​​же проблему, создающую исчерпание TCP-порта на клиенте. Решение заключалось в том, чтобы арендовать экземпляр HttpClient на длительные периоды времени, когда выполнялись итеративные вызовы, а не создавать и удалять для каждого вызова. Я пришел к выводу: «Только потому, что он реализует Dispose, это не значит, что его использование дешево».
PhillipH
поэтому, если HttpClient статичен, и мне нужно изменить заголовок при следующем запросе, что это делает с первым запросом? Есть ли вред в изменении HttpClient, поскольку он статичен - например, выдача HttpClient.DefaultRequestHeaders.Accept.Clear (); ? Например, если у меня есть пользователи, которые проходят аутентификацию с помощью токенов, эти токены необходимо добавить в качестве заголовков в запрос к API, из которых разные токены. Разве статический HttpClient и последующее изменение этого заголовка на HttpClient не имели бы неблагоприятных последствий?
Crizzwald
Если вам нужно использовать элементы экземпляра HttpClient, такие как заголовки / файлы cookie и т. Д., Вам не следует использовать статический HttpClient. В противном случае данные вашего экземпляра (заголовки, файлы cookie) будут одинаковыми для каждого запроса - конечно, НЕ то, что вы хотите.
Дэйв Блэк
раз уж дело обстоит именно так ... как бы вы предотвратили то, что вы описываете выше в своем сообщении - от нагрузки? балансировщик нагрузки и добавить к нему больше серверов?
crizzwald
@crizzwald - В своем посте я отметил используемое решение. Используйте статический экземпляр HttpClient. Если вам нужно использовать заголовок / файлы cookie в HttpClient, я бы поискал альтернативу.
Дэйв Блэк