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