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


4
HttpClientслід поважати ServicePointManager.DefaultConnectionLimit.
Стівен Клірі

2
Ваші спостереження, здається, варто вивчити. Одне мене турбує одне: я думаю, що дуже надумано видавати тисячі асинхронних IO одночасно. Я б ніколи цього не робив на виробництві. Той факт, що ви асинхроніст, не означає, що ви можете збиватися, споживаючи різні ресурси. (Офіційні зразки Microsofts також трохи вводять в оману в цьому плані.)
usr

1
Однак не затухайте з затримкою в часі. Дросель на фіксованому рівні одночасності, який ви визначаєте емпірично. Простим рішенням буде SemaphoreSlim.WaitAsync, хоча це також не підходить для довільно великої кількості завдань.
usr

1
@FlorinDumitrescu Для дроселювання можна використовувати SemaphoreSlim, як уже було сказано, або ActionBlock<T>з потоку даних TPL.
svick

1
@svick, дякую за ваші пропозиції. Мене не цікавить вручну впровадити механізм обмеження / обмеження одночасності. Як вже було сказано, реалізація, включена в моє запитання, була лише для тестування та для перевірки теорії. Я не намагаюся її вдосконалити, оскільки це не потрапить до виробництва. Мене цікавить, якщо рамка .Net пропонує вбудований механізм обмеження одночасності операцій асинхронного вводу-виводу (включений HttpClient).
Флорін Думітреску

Відповіді:


64

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

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

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

Отже, async HttpClient не є вузьким місцем для інтенсивного застосування навантажень. Це просто те, що за своєю природою він не дуже добре підходить для дуже швидких HTTP-запитів, натомість ідеально підходить для довгих або потенційно довгих запитів, особливо всередині додатків, які мають лише обмежену кількість потоків. Крім того, є хорошою практикою обмежувати паралельність за допомогою ServicePointManager.DefaultConnectionLimit зі значенням, яке є достатньо високим, щоб забезпечити хороший рівень паралелізму, але досить низьким, щоб запобігти виснаженню ефемерного порту. Більш детальну інформацію про тести та висновки, представлені з цього питання, ви можете знайти тут .


3
Наскільки швидко "дуже швидко"? 1 мс? 100 мс? 1000 мс?
Тім П.

Я використовую щось на зразок вашого "асинхронного" підходу для відтворення завантаження на веб-сервері WebLogic, розгорнутому в Windows, але я швидко переживаю проблему виснаження ефемерних портів. Я не торкнувся ServicePointManager.DefaultConnectionLimit, і я розпоряджаюся та відтворюю все (HttpClient та відповідь) на кожен запит. Чи маєте ви уявлення про те, що може спричинити, щоб з'єднання залишалися відкритими та виснажували порти?
Іраванчі

@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
Michael Freidgeim

1
@MichaelFreidgeim: Дякую, хоча я вже навчився жити з HttpClient ...
mortb

17

Хоча це не відповідає безпосередньо на "асинхронну" частину питання ОП, це стосується помилки в застосуванні, яке він використовує.

Якщо ви хочете, щоб ваша програма масштабувалась, не використовуйте HttpClients на основі примірників. Різниця ВЕЛИЧЕЗНА! Залежно від навантаження, ви побачите дуже різні показники продуктивності. HttpClient був розроблений для повторного використання в запитах. Це підтвердили хлопці з команди BCL, які це написали.

Нещодавній проект, який я мав, - допомогти дуже великій і відомій мережі роздрібної торгівлі комп'ютером для Чорної п’ятниці / святкового руху для деяких нових систем. Ми зіткнулися з деякими проблемами продуктивності навколо використання HttpClient. Оскільки він реалізується IDisposable, розробники зробили те, що зазвичай робили, створивши екземпляр і помістивши його всередину using()оператора. Як тільки ми розпочали тестування завантаження, додав сервер на коліна - так, сервер не просто додаток. Причина полягає в тому, що кожен екземпляр HttpClient відкриває порт завершення вводу / виводу на сервері. Через недетерміновану доопрацювання GC та той факт, що ви працюєте з комп’ютерними ресурсами, що охоплюють декілька шарів OSI , закриття мережевих портів може зайняти деякий час. Насправді сама ОС Windowsможе закрити порт (на Microsoft) до 20 секунд. Ми відкривали порти швидше, ніж їх можна було закрити - виснаження портів сервера, що забило процесор на 100%. Моє виправлення полягало в тому, щоб змінити HttpClient на статичний екземпляр, який вирішив проблему. Так, це одноразовий ресурс, але будь-які накладні витрати значно переважають різниця в продуктивності. Я рекомендую зробити тестування навантаження, щоб побачити, як поводиться ваш додаток.

Також відповів за посиланням нижче:

Яка витрата на створення нового HttpClient за виклик у клієнта WebAPI?

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


Я знайшов саме таку проблему, що створює виснаження TCP-портів для клієнта. Рішенням було взяти в оренду екземпляр HttpClient на тривалі періоди часу, коли здійснювались ітеративні дзвінки, а не створювати та розпоряджатися кожним викликом. Висновок, до якого я дійшов, був «Просто тому, що він реалізує розпорядження, це не означає, що його дешево розпоряджатись».
PhillipH

тому якщо HttpClient статичний, і мені потрібно змінити заголовок при наступному запиті, що це робити з першим запитом? Чи є якась шкода в зміні HttpClient, оскільки він статичний - наприклад, видача HttpClient.DefaultRequestHeaders.Accept.Clear (); ? Наприклад, якщо у мене є користувачі, що підтверджують автентичність за допомогою токенів, ці маркери потрібно додавати як заголовки на запит до API, з яких це різні маркери. Чи не матиме HttpClient статичним, а потім зміна цього заголовка на HttpClient має негативний вплив?
crizzwald

Якщо вам потрібно використовувати члени екземплярів HttpClient, такі як заголовки / файли cookie тощо, ви не повинні використовувати статичний HttpClient. В іншому випадку ваші дані екземплярів (заголовки, файли cookie) будуть однаковими для кожного запиту - звичайно НЕ тим, що потрібно.
Дейв Блек

оскільки це так ... як би ви запобігли тому, що ви описуєте вище у своєму посту - проти навантаження? завантажувати балансир і кидати на нього більше серверів?
crizzwald

@crizzwald - У своєму дописі я зазначив застосоване рішення. Використовуйте статичний екземпляр HttpClient. Якщо вам потрібно використовувати заголовок / cookie в HttpClient, я б хотів використати альтернативу.
Дейв Блек
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.