Недавно я создал простое приложение для тестирования пропускной способности 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, похоже, обеспечивает надежность (по крайней мере, для сценария, когда все вызовы выполняются к одному и тому же хосту), похоже, что на его производительность отрицательно влияет отсутствие надлежащего механизма регулирования. Что-то, что ограничивало бы количество одновременных запросов до настраиваемого значения, а остальные помещало бы в очередь, сделало бы его более подходящим для сценариев с высокой масштабируемостью.
SemaphoreSlim
, как уже упоминалось, или ActionBlock<T>
из TPL Dataflow.
HttpClient
следует уважатьServicePointManager.DefaultConnectionLimit
.