Czy async HttpClient z .Net 4.5 to zły wybór w przypadku aplikacji o dużym obciążeniu?

130

Niedawno stworzyłem prostą aplikację do testowania przepustowości wywołań HTTP, która może być generowana w sposób asynchroniczny w porównaniu z klasycznym podejściem wielowątkowym.

Aplikacja jest w stanie wykonać określoną liczbę wywołań HTTP, a na końcu wyświetla całkowity czas potrzebny na ich wykonanie. Podczas moich testów wszystkie wywołania HTTP były kierowane do mojego lokalnego serwera IIS i pobierały mały plik tekstowy (o rozmiarze 12 bajtów).

Najważniejsza część kodu implementacji asynchronicznej jest wymieniona poniżej:

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();
        }
    }
}

Najważniejsza część implementacji wielowątkowości jest wymieniona poniżej:

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();
    }
}

Przeprowadzenie testów wykazało, że wersja wielowątkowa była szybsza. Wykonanie 10k żądań zajęło około 0,6 sekundy, podczas gdy wykonanie asynchroniczne zajęło około 2 sekundy przy takim samym obciążeniu. To była trochę niespodzianka, ponieważ spodziewałem się, że asynchroniczny będzie szybszy. Może dlatego, że moje wywołania HTTP były bardzo szybkie. W prawdziwym scenariuszu, w którym serwer powinien wykonać bardziej znaczącą operację i gdzie powinno być również pewne opóźnienie w sieci, wyniki mogą być odwrócone.

Jednak to, co naprawdę mnie niepokoi, to sposób, w jaki HttpClient zachowuje się, gdy obciążenie jest zwiększone. Ponieważ dostarczenie 10 000 wiadomości zajmuje około 2 sekund, pomyślałem, że dostarczenie 10 razy większej liczby wiadomości zajmie około 20 sekund, ale test wykazał, że potrzeba około 50 sekund, aby dostarczyć 100 000 wiadomości. Co więcej, dostarczenie 200 tys. Wiadomości zwykle zajmuje więcej niż 2 minuty, a często kilka tysięcy z nich (3-4 tys.) Kończy się niepowodzeniem z następującym wyjątkiem:

Nie można wykonać operacji na gnieździe, ponieważ w systemie brakowało miejsca w buforze lub ponieważ kolejka była pełna.

Sprawdziłem dzienniki IIS i operacje, które się nie powiodły, nigdy nie dotarły do ​​serwera. Zawiedli w kliencie. Testy przeprowadziłem na komputerze z systemem Windows 7 z domyślnym zakresem portów efemerycznych od 49152 do 65535. Uruchomienie netstata wykazało, że podczas testów było używanych około 5-6 tys. Portów, więc teoretycznie powinno być ich znacznie więcej. Jeśli brak portów był rzeczywiście przyczyną wyjątków, oznacza to, że albo netstat nie zgłosił poprawnie sytuacji, albo HttClient używa tylko maksymalnej liczby portów, po których zaczyna rzucać wyjątki.

Z kolei podejście wielowątkowe polegające na generowaniu wywołań HTTP było bardzo przewidywalne. Zajęło mi to około 0,6 sekundy dla 10 tysięcy wiadomości, około 5,5 sekundy dla 100 tysięcy i zgodnie z oczekiwaniami około 55 sekund dla 1 miliona wiadomości. Żadna z wiadomości nie zakończyła się niepowodzeniem. Co więcej, podczas gdy działał, nigdy nie używał więcej niż 55 MB pamięci RAM (według Menedżera zadań systemu Windows). Pamięć używana podczas wysyłania wiadomości asynchronicznie rosła proporcjonalnie do obciążenia. Używał około 500 MB pamięci RAM podczas testów 200k wiadomości.

Myślę, że są dwa główne powody powyższych wyników. Po pierwsze, HttpClient wydaje się być bardzo chciwy w tworzeniu nowych połączeń z serwerem. Wysoka liczba używanych portów zgłaszana przez netstat oznacza, że ​​prawdopodobnie nie skorzysta zbytnio na utrzymywaniu aktywności HTTP.

Po drugie, HttpClient nie wydaje się mieć mechanizmu dławienia. W rzeczywistości wydaje się to być ogólnym problemem związanym z operacjami asynchronicznymi. Jeśli musisz wykonać bardzo dużą liczbę operacji, wszystkie zostaną uruchomione naraz, a następnie ich kontynuacje będą wykonywane, gdy będą dostępne. Teoretycznie powinno to być w porządku, ponieważ w operacjach asynchronicznych obciążenie jest na zewnętrznych systemach, ale jak udowodniono powyżej, nie jest to całkowicie prawdą. Posiadanie dużej liczby żądań uruchomionych jednocześnie zwiększy użycie pamięci i spowolni całe wykonanie.

Udało mi się uzyskać lepsze wyniki, jeśli chodzi o pamięć i czas wykonywania, ograniczając maksymalną liczbę asynchronicznych żądań za pomocą prostego, ale prymitywnego mechanizmu opóźnienia:

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);
    }
}

Byłoby naprawdę przydatne, gdyby HttpClient zawierał mechanizm ograniczania liczby równoczesnych żądań. W przypadku korzystania z klasy Task (opartej na puli wątków .Net) dławienie jest osiągane automatycznie poprzez ograniczenie liczby współbieżnych wątków.

Aby uzyskać pełny przegląd, utworzyłem również wersję testu asynchronicznego opartą na HttpWebRequest zamiast HttpClient i udało mi się uzyskać znacznie lepsze wyniki. Na początek pozwala ustawić limit liczby jednoczesnych połączeń (z ServicePointManager.DefaultConnectionLimit lub przez konfigurację), co oznacza, że ​​nigdy nie zabrakło mu portów i nigdy nie zakończyło się niepowodzeniem na żadnym żądaniu (domyślnie HttpClient opiera się na HttpWebRequest , ale wydaje się ignorować ustawienie limitu połączeń).

Podejście asynchroniczne HttpWebRequest było nadal około 50-60% wolniejsze niż podejście wielowątkowe, ale było przewidywalne i niezawodne. Jedynym minusem było to, że zużywał ogromną ilość pamięci pod dużym obciążeniem. Na przykład potrzebował około 1,6 GB na wysłanie 1 miliona żądań. Ograniczając liczbę równoczesnych żądań (tak jak zrobiłem to powyżej dla HttpClient) udało mi się zmniejszyć używaną pamięć do zaledwie 20 MB i uzyskać czas wykonywania zaledwie o 10% wolniejszy niż podejście wielowątkowe.

Po tej długiej prezentacji moje pytania są następujące: Czy klasa HttpClient z .Net 4.5 jest złym wyborem dla aplikacji intensywnie obciążających? Czy jest jakiś sposób na zdławienie tego, który powinien rozwiązać problemy, o których wspominam? A co z asynchronicznym smakiem HttpWebRequest?

Aktualizacja (dzięki @Stephen Cleary)

Jak się okazuje, HttpClient, podobnie jak HttpWebRequest (na którym jest domyślnie oparty), może mieć ograniczoną liczbę jednoczesnych połączeń na tym samym hoście za pomocą usługi ServicePointManager.DefaultConnectionLimit. Dziwne jest to, że według MSDN domyślną wartością limitu połączenia jest 2. Sprawdziłem to również po mojej stronie za pomocą debugera, który wskazał, że rzeczywiście 2 jest wartością domyślną. Wygląda jednak na to, że o ile nie zostanie jawnie ustawiona wartość ServicePointManager.DefaultConnectionLimit, wartość domyślna zostanie zignorowana. Ponieważ nie ustawiłem jawnie wartości podczas moich testów HttpClient, pomyślałem, że została zignorowana.

Po ustawieniu ServicePointManager.DefaultConnectionLimit na 100 HttpClient stał się niezawodny i przewidywalny (netstat potwierdza, że ​​używane jest tylko 100 portów). Nadal jest wolniejszy niż asynchroniczny HttpWebRequest (o około 40%), ale, co dziwne, zużywa mniej pamięci. W przypadku testu obejmującego 1 milion żądań wykorzystano maksymalnie 550 MB, w porównaniu z 1,6 GB w asynchronicznym HttpWebRequest.

Tak więc, chociaż HttpClient w połączeniu z ServicePointManager.DefaultConnectionLimit wydaje się zapewniać niezawodność (przynajmniej w scenariuszu, w którym wszystkie wywołania są kierowane do tego samego hosta), nadal wygląda na to, że na jego wydajność negatywnie wpływa brak odpowiedniego mechanizmu dławienia. Coś, co ograniczyłoby liczbę jednoczesnych żądań do konfigurowalnej wartości i umieściło resztę w kolejce, sprawiłoby, że byłoby to znacznie bardziej odpowiednie dla scenariuszy o wysokiej skalowalności.

Florin Dumitrescu
źródło
4
HttpClientpowinien szanować ServicePointManager.DefaultConnectionLimit.
Stephen Cleary
2
Twoje obserwacje wydają się warte zbadania. Jedna rzecz mnie niepokoi: myślę, że jest wysoce wymyślne, aby wystawiać tysiące asynchronicznych operacji we / wy na raz. Nigdy bym tego nie zrobił na produkcji. Fakt, że jesteś asynchroniczny, nie oznacza, że ​​możesz oszaleć, zużywając różne zasoby. (Oficjalne próbki Microsoftu również są trochę mylące)
usr
1
Nie ograniczaj jednak opóźnień czasowych. Ogranicz na ustalonym poziomie współbieżności, który określisz empirycznie. Prostym rozwiązaniem byłoby SemaphoreSlim.WaitAsync, chociaż nie byłoby to również odpowiednie dla dowolnie dużej liczby zadań.
usr
1
@FlorinDumitrescu Do dławienia można użyć SemaphoreSlim, jak już wspomniano, lub ActionBlock<T>z TPL Dataflow.
svick
1
@svick, dzięki za sugestie. Nie jestem zainteresowany ręcznym wdrażaniem mechanizmu dławienia / ograniczenia współbieżności. Jak wspomniałem, implementacja zawarta w moim pytaniu służyła tylko do testowania i walidacji teorii. Nie próbuję go ulepszać, ponieważ nie trafi do produkcji. Interesuje mnie to, czy platforma .Net oferuje wbudowany mechanizm ograniczania współbieżności asynchronicznych operacji we / wy (w tym HttpClient).
Florin Dumitrescu

Odpowiedzi:

64

Oprócz testów, o których mowa w pytaniu, niedawno utworzyłem kilka nowych, obejmujących znacznie mniej wywołań HTTP (5000 w porównaniu do 1 miliona wcześniej), ale na żądaniach, których wykonanie trwało znacznie dłużej (500 milisekund w porównaniu z około 1 milisekundą poprzednio). Obie aplikacje testowe, synchroniczna wielowątkowa (oparta na HttpWebRequest) i asynchroniczna I / O (oparta na kliencie HTTP) dały podobne wyniki: około 10 sekund na wykonanie przy użyciu około 3% procesora i 30 MB pamięci. Jedyna różnica między tymi dwoma testerami polegała na tym, że wielowątkowy używał 310 wątków do wykonania, a asynchroniczny tylko 22.

Podsumowując moje testy, asynchroniczne wywołania HTTP nie są najlepszą opcją w przypadku bardzo szybkich żądań. Powodem tego jest to, że podczas uruchamiania zadania, które zawiera asynchroniczne wywołanie we / wy, wątek, w którym zadanie jest uruchamiane, jest zamykany, gdy tylko zostanie wykonane wywołanie asynchroniczne, a reszta zadania zostanie zarejestrowana jako wywołanie zwrotne. Następnie po zakończeniu operacji we / wy wywołanie zwrotne jest umieszczane w kolejce do wykonania w pierwszym dostępnym wątku. Wszystko to tworzy narzut, który sprawia, że ​​szybkie operacje we / wy są bardziej wydajne, gdy są wykonywane w wątku, który je rozpoczął.

Asynchroniczne wywołania HTTP są dobrą opcją w przypadku długich lub potencjalnie długich operacji we / wy, ponieważ nie powodują zajętości żadnych wątków w oczekiwaniu na zakończenie operacji we / wy. Zmniejsza to ogólną liczbę wątków używanych przez aplikację, umożliwiając więcej czasu procesora przeznaczonego na operacje związane z procesorem. Ponadto w aplikacjach, które przydzielają tylko ograniczoną liczbę wątków (tak jak w przypadku aplikacji internetowych), asynchroniczne operacje we / wy zapobiegają wyczerpywaniu się wątków w puli wątków, co może się zdarzyć, jeśli wywołania we / wy są wykonywane synchronicznie.

Tak więc async HttpClient nie jest wąskim gardłem dla aplikacji o dużym obciążeniu. Chodzi po prostu o to, że ze swej natury nie jest zbyt dobrze dostosowany do bardzo szybkich żądań HTTP, zamiast tego jest idealny do długich lub potencjalnie długich, szczególnie w aplikacjach, które mają tylko ograniczoną liczbę dostępnych wątków. Ponadto dobrą praktyką jest ograniczenie współbieżności za pośrednictwem ServicePointManager.DefaultConnectionLimit z wartością, która jest wystarczająco wysoka, aby zapewnić dobry poziom równoległości, ale wystarczająco niska, aby zapobiec efemerycznemu wyczerpywaniu portu. Więcej informacji na temat testów i wniosków przedstawionych dla tego pytania można znaleźć tutaj .

Florin Dumitrescu
źródło
3
Jak szybko jest „bardzo szybko”? 1 ms? 100 ms? 1000 ms?
Tim P.
Używam czegoś takiego jak Twoje podejście „asynchroniczne” do odtwarzania obciążenia na serwerze WWW WebLogic wdrożonym w systemie Windows, ale dość szybko pojawia się problem z wyczerpywaniem się portów. Nie dotknąłem ServicePointManager.DefaultConnectionLimit i usuwam i ponownie tworzę wszystko (HttpClient i odpowiedź) na każde żądanie. Czy masz pojęcie, co może powodować, że połączenia pozostają otwarte i wyczerpują porty?
Iravanchi
@TimP. w moich testach, jak wspomniano powyżej, „bardzo szybko” były żądania, których wykonanie zajmowało zaledwie 1 milisekundę. W prawdziwym świecie zawsze będzie to subiektywne. Z mojego punktu widzenia coś równoważnego niewielkiemu zapytaniu w lokalnej sieciowej bazie danych można uznać za szybkie, podczas gdy coś równoważnego z wywołaniem interfejsu API przez internet można uznać za powolne lub potencjalnie wolne.
Florin Dumitrescu
1
@Iravanchi, w podejściu "async", wysyłanie żądań i obsługa odpowiedzi są wykonywane osobno. Jeśli masz dużo połączeń, wszystkie prośby zostaną wysłane bardzo szybko, a odpowiedzi będą obsługiwane po ich otrzymaniu. Ponieważ możesz pozbyć się połączeń dopiero po nadejściu ich odpowiedzi, duża liczba równoczesnych połączeń może się skumulować i wyczerpać twoje efemeryczne porty. Należy ograniczyć maksymalną liczbę równoczesnych połączeń przy użyciu usługi ServicePointManager.DefaultConnectionLimit.
Florin Dumitrescu
1
@FlorinDumitrescu, dodałbym również, że połączenia sieciowe są z natury nieprzewidywalne. Rzeczy, które działają w ciągu 10 ms przez 90% czasu, mogą powodować problemy z blokowaniem, gdy ten zasób sieciowy jest przeciążony lub niedostępny w pozostałych 10%.
Tim P.
27

Jedną rzeczą do rozważenia, która może wpływać na twoje wyniki, jest to, że w przypadku HttpWebRequest nie otrzymujesz ResponseStream i nie zużywasz tego strumienia. Z HttpClient, domyślnie skopiuje strumień sieciowy do strumienia pamięci. Aby używać HttpClient w ten sam sposób, w jaki obecnie używasz HttpWebRquest, musisz zrobić

var requestMessage = new HttpRequestMessage() {RequestUri = URL};
Task<HttpResponseMessage> getTask = httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead);

Inną rzeczą jest to, że nie jestem do końca pewien, jaka jest prawdziwa różnica, z perspektywy wątków, tak naprawdę testujesz. Jeśli zagłębisz się w głębię HttpClientHandler, po prostu wykona Task.Factory.StartNew w celu wykonania żądania asynchronicznego. Zachowanie wątkowości jest delegowane do kontekstu synchronizacji dokładnie w taki sam sposób, jak w przypadku przykładu z przykładem HttpWebRequest.

Niewątpliwie HttpClient dodaje trochę narzutu, ponieważ domyślnie używa HttpWebRequest jako swojej biblioteki transportowej. Dzięki temu zawsze będziesz w stanie uzyskać lepszą wydajność za pomocą HttpWebRequest bezpośrednio podczas korzystania z HttpClientHandler. Korzyści, jakie przynosi HttpClient, to standardowe klasy, takie jak HttpResponseMessage, HttpRequestMessage, HttpContent i wszystkie silnie wpisane nagłówki. Samo w sobie nie jest to optymalizacja perfekcyjna.

Darrel Miller
źródło
(stara odpowiedź, ale) HttpClientwydaje się łatwa w użyciu i myślałem, że asynchroniczność to najlepszy sposób, ale wydaje się, że jest wiele „ale i jeśli” wokół tego. Może HttpClientnależałoby przepisać go tak, aby był bardziej intuicyjny w obsłudze? Albo że dokumentacja naprawdę podkreślała ważne rzeczy dotyczące tego, jak z niej korzystać najefektywniej?
mortb
@mortb, Flurl.Http flurl.io jest bardziej intuicyjnym w użyciu opakowaniem HttpClient
Michael Freidgeim
1
@MichaelFreidgeim: Dzięki, chociaż nauczyłem się już żyć z HttpClientem ...
mortb
17

Chociaż nie stanowi to bezpośredniej odpowiedzi na „asynchroniczną” część pytania PO, jest to rozwiązanie problemu błędu w implementacji, z którego korzysta.

Jeśli chcesz, aby aplikacja była skalowalna, unikaj korzystania z HttpClients opartej na wystąpieniach. Różnica jest OGROMNA! W zależności od obciążenia zobaczysz bardzo różne wartości wydajności. HttpClient został zaprojektowany do ponownego użycia w żądaniach. Zostało to potwierdzone przez facetów z zespołu BCL, którzy to napisali.

Niedawnym projektem, który miałem, była pomoc bardzo dużemu i znanemu sklepowi internetowemu z komputerami, który dostosował się do ruchu w Czarny piątek / święta dla niektórych nowych systemów. Wystąpiły problemy z wydajnością związane z użyciem HttpClient. Ponieważ implementuje IDisposable, programiści zrobili to, co normalnie zrobiłbyś, tworząc instancję i umieszczając ją w using()instrukcji. Gdy rozpoczęliśmy testy obciążenia, aplikacja rzuciła serwer na kolana - tak, serwer, a nie tylko aplikacja. Powodem jest to, że każde wystąpienie HttpClient otwiera port zakończenia we / wy na serwerze. Ze względu na niedeterministyczną finalizację GC i fakt, że pracujesz z zasobami komputera obejmującymi wiele warstw OSI , zamykanie portów sieciowych może trochę potrwać. W rzeczywistości sam system operacyjny WindowsZamknięcie portu może zająć do 20 sekund (na Microsoft). Otwieraliśmy porty szybciej, niż można było je zamknąć - wyczerpanie portu serwera, które obciążyło procesor do 100%. Moja poprawka polegała na zmianie HttpClient na instancję statyczną, co rozwiązało problem. Tak, jest to zasób jednorazowy, ale różnica w wydajności znacznie przewyższa wszelkie koszty ogólne. Zachęcam do przeprowadzenia testów obciążenia, aby zobaczyć, jak zachowuje się Twoja aplikacja.

Odpowiedziałem również pod linkiem poniżej:

Jakie są narzuty związane z tworzeniem nowego HttpClient na wywołanie w kliencie WebAPI?

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

Dave Black
źródło
Znalazłem dokładnie ten sam problem powodujący wyczerpanie portu TCP na kliencie. Rozwiązaniem było wydzierżawienie wystąpienia HttpClient na długi okres czasu, w którym wykonywane były wywołania iteracyjne, a nie tworzenie i usuwanie dla każdego wywołania. Wniosek, do którego doszedłem, brzmiał: „Tylko dlatego, że wdraża Dispose, nie oznacza to, że jest tani w utylizacji”.
PhillipH
więc jeśli HttpClient jest statyczny i muszę zmienić nagłówek w następnym żądaniu, co to robi z pierwszym żądaniem? Czy zmiana HttpClient jest jakaś szkodliwa, ponieważ jest statyczna - na przykład wydanie HttpClient.DefaultRequestHeaders.Accept.Clear (); ? Na przykład, jeśli mam użytkowników, którzy uwierzytelniają się za pomocą tokenów, te tokeny muszą zostać dodane jako nagłówki w żądaniu do API, które są różnymi tokenami. Czy posiadanie HttpClient jako statycznego, a następnie zmiana tego nagłówka na HttpClient nie miałoby niekorzystnych skutków?
crizzwald
Jeśli potrzebujesz użyć elementów członkowskich wystąpienia HttpClient, takich jak nagłówki / pliki cookie itp., Nie powinieneś używać statycznego HttpClient. W przeciwnym razie dane Twojej instancji (nagłówki, pliki cookie) byłyby takie same dla każdego żądania - na pewno NIE to, czego chcesz.
Dave Black
skoro tak jest ... jak możesz zapobiec temu, co opisujesz powyżej w swoim poście - przed obciążeniem? load balancer i rzucić na niego więcej serwerów?
crizzwald
@crizzwald - W moim poście zanotowałem zastosowane rozwiązanie. Użyj statycznego wystąpienia HttpClient. Jeśli potrzebujesz użyć nagłówka / plików cookie w HttpClient, chciałbym użyć alternatywy.
Dave Black