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.
źródło
HttpClient
powinien szanowaćServicePointManager.DefaultConnectionLimit
.SemaphoreSlim
, jak już wspomniano, lubActionBlock<T>
z TPL Dataflow.Odpowiedzi:
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 .
źródło
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.
źródło
HttpClient
wydaje 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żeHttpClient
należ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?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ą wusing()
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
źródło