Нещодавно я створив просту програму для тестування пропускної здатності виклику 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 поводить себе при збільшенні навантаження. Оскільки для того, щоб доставити 10k повідомлень, потрібно близько 2 секунд, я подумав, що для доставки 100k повідомлень знадобиться приблизно 20 секунд, щоб доставити 10-кратну кількість повідомлень, але, запустивши тест, було потрібно близько 50 секунд. Крім того, для доставки 200k повідомлень зазвичай потрібно більше 2 хвилин, і часто, кілька тисяч з них (3-4 к), виходять з ладу за наступним винятком:
Операцію над сокетом не вдалося виконати через те, що в системі бракувало достатнього буферного простору або через те, що черга була заповнена.
Я перевірив журнали IIS та операції, які не вдалося ніколи не потрапити на сервер. Вони не вдалися до клієнта. Я проводив тести на машині Windows 7 з діапазоном ефемерних портів за замовчуванням від 49152 до 65535. Запуск netstat показав, що під час тестів використовувалося близько 5-6K портів, тому теоретично було б набагато більше доступних. Якщо відсутність портів справді була причиною винятків, це означає, що або netstat не повідомив належним чином про ситуацію, або HttClient використовує лише максимальну кількість портів, після чого починає викидати винятки.
На відміну від цього, багатопотоковий підхід для генерування HTTP-дзвінків поводився дуже передбачувано. Я зайняв це приблизно 0,6 секунди для 10 000 повідомлень, близько 5,5 секунд для 100 000 повідомлень і, як очікувалося, близько 55 секунд для 1 мільйона повідомлень. Жодне з повідомлень не вдалося. Більше того, поки він працював, він ніколи не використовував більше 55 Мб оперативної пам’яті (за даними Windows Task Manager). Пам'ять, що використовується для асинхронного надсилання повідомлень, пропорційно зростала з навантаженням. Під час тестів на 200 тис. Повідомлень було використано близько 500 МБ оперативної пам’яті.
Я думаю, що для вищезазначених результатів є дві основні причини. Перший - 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 або через config), що означає, що він ніколи не закінчувався портами і ніколи не виходив з ладу під будь-яким запитом (HttpClient, за замовчуванням, заснований на HttpWebRequest , але, здається, ігнорується встановлення межі з'єднання).
Підхід async 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, здається, забезпечує надійність (принаймні, для сценарію, коли всі дзвінки здійснюються до одного і того ж хоста), все одно схоже, що на його ефективність негативно впливає відсутність належного механізму дроселювання. Щось, що обмежило б одночасну кількість запитів до настроюваного значення, а решту поставило б у черзі, зробило б його набагато більш придатним для сценаріїв високої масштабованості.
SemaphoreSlim
, як уже було сказано, або ActionBlock<T>
з потоку даних TPL.
HttpClient
слід поважатиServicePointManager.DefaultConnectionLimit
.