Wprowadzenie

Większość projektów, które ostatnio wykonuje bazuje na technologii .NET Core. Chciałem również dowiedzieć się czegoś na temat zwiększania RPS (Receive Pocket Steering) po stronie backenu. Jest to związane z licznymi artykułami, które mowią, że wątki w .NET są ciężki a środowisko używa schematu M:M co oznacza, że system wykonuje przełączanie kontekstu pomiędzy wątkami. Jest to niezwykle ważne w przypadku aplikacji internetowej ponieważ wpływa na wszystkie operacje wejścia/wyjścia w przypadku bazy danych co przekłada się na konieczność blokowania i zwiększenia wykorzystania zasobów systemowych w przypadku API.

Na całe szczęście .NET zapewnia wsparcie połączeń async/await, które konwertują obsługiwane wywołania asynchroniczne na wywołania zwrotne oraz „uwalniają” wątek, aby pozwolić na wykonanie innych operacji typu I/O. Nie byłem jednak w stanie znaleźć dokumentacji na temat obsługi tego typu żądań w .NET Core MVC. Przeprowadziłem jednak testy, które pokazały, że platforma używa dynaminczej puli wątków, aby odpowiadać na przychodzące żądania i ponownie używać wątków, gdy są używane kontrolery asynchroniczne.


Podejście tradycyjne

// GET api/values/sleep
[HttpGet("sleep")]
public string GetSleep()
{
	Thread.Sleep(1000);
	return Process.GetCurrentProcess().Threads.Count.ToString();
}

Powyższa metoda używa Thread.Sleep, aby wstrzymać wykonywanie bieżącego wątku. Wydajność jest dość niska mimo, że żądanie zostaje uśpione tylko na 1 sekundę. Możemy jednak zobaczyć, że odpowiedź pojawia się dopiero po około 57 sekundach od uśpienia. Warto również zwrócić uwagę na średni czas odpowiedzi ze strony serwera. Czas odpowiedzi nie jest jednostajny (większy wykres po wyświetlniu żródła).
Sekwencyjne wykonywanie requestów

Z kolei poniższy wykres weryfikuje jedną niezwykle ważną rzecz - .NET Core MVC używa dynamicznej puli wątków, aby obsługiwać żądania. Gdy zwrasta liczba żądań, .NET dodaje więcej wątków systemowych do przetwarzania żądań.
Sekwencyjne wykonywanie requestów - liczba wątków

Na ratunek: Async

// GET api/values/delay
[HttpGet("delay")]
async public Task<string> GetDelay()
{
	await Task.Delay(1000);
	return Process.GetCurrentProcess().Threads.Count.ToString();
}

Co za ulga! Task.Delay używa async/await a średni czas odpowiedzi jest taki jak powienien być – 1 sekunda.

Asynchroniczne wykonanie żądania

Warto również przedstawić wykres dotyczący liczby wątków. Na poniższym przykładzie możemy zauważyć podobną liczbę watków startowych, potem ich niewielki wzrost oraz ustabilizowanie. Kolejne wątki nie są twrzone jak to miało w poprzednim przykładzie. Pula wątków rośnie do 30 i pozostaje na tym poziomie do samego końca. W obu przypadków widać, że .NET zaczyna od około 24 wątków przy pierwszym żądaniu. Większość z nich to prawdopodobnie wątki związane ze środowiskiem takie jak Kestrel, serwer HTTP czy Garbage Collector, itd. Jesteśmy zainteresowani każdym z dodatkowych watków ponad liczbę 24.
Asynchroniczne wykonanie żądania - liczba wątków

Jeżeli chcemy znaleźć ucieczkę od takiego podejścia... musielibyśmy znaleźć sterownik basy danych, który implementuje metody asynchroniczne i skonfigurować wszystkie kontrolery aby używały metod asynchronicznych. .NET będzie używał wydajnie wątków w swojej dynamicznej puli wątków a Ty będziesz w stanie wykorzystać więcej wydajności z każdego serwera WWW.

Sposób przeprowadzania testów

Do powyższych testów użyłem oprogramowania Webserver Stress Tool:

  • Uruchomienie 50 żądań na sekunde przez 60 sekund;
  • Rejestrowanie liczby wątków w każdej sekundzie wykonywania żądania