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.
HttpClient
powinien szanowaćServicePointManager.DefaultConnectionLimit
.