Antywzorzec synchronicznych operacji we/wy

Blokowanie wątku wywołującego podczas kończenia operacji We/Wy może zmniejszyć wydajność i mieć wpływ na skalowalność pionową.

Opis problemu

Synchroniczne operacje we/wy blokują wątek wywołujący podczas kończenia operacji we/wy. Wątek wywołujący przechodzi w stan oczekiwania i nie może wykonać przydatnych działań w tym przedziale czasu, marnując zasoby przetwarzania.

Przykłady typowych operacji We/Wy obejmują:

  • Pobieranie lub utrwalanie danych w bazie danych lub dowolnym typie magazynu trwałego.
  • Wysyłanie żądania do usługi internetowej.
  • Publikowanie komunikatu lub pobieranie komunikatu z kolejki.
  • Zapisywanie do lub odczytywanie z pliku lokalnego.

Oto typowe przyczyny występowania tego antywzorca:

  • Wydaje się być najbardziej intuicyjnym sposobem wykonania operacji.
  • Aplikacja wymaga odpowiedzi od żądania.
  • Aplikacja używa biblioteki, która udostępnia tylko synchroniczne metody operacji we/wy.
  • Biblioteka zewnętrzna wykonuje wewnętrznie synchroniczne operacje we/wy. Pojedyncze wywołanie synchronicznej operacji we/wy może zablokować cały łańcuch wywołań.

Poniższy kod przekazuje plik do magazynu obiektów blob platformy Azure. Istnieją dwa miejsca, gdzie bloki kodu oczekują na synchroniczną operację we/wy: metoda CreateIfNotExists i metoda UploadFromStream.

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

container.CreateIfNotExists();
var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    blockBlob.UploadFromStream(fileStream);
}

Oto przykład oczekiwania na odpowiedź z zewnętrznej usługi. Metoda GetUserProfile wywołuje usługę zdalną, która zwraca UserProfile.

public interface IUserProfileService
{
    UserProfile GetUserProfile();
}

public class SyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public SyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the synchronous GetUserProfile method.
    public UserProfile GetUserProfile()
    {
        return _userProfileService.GetUserProfile();
    }
}

Kompletny kod dla obu tych przykładów możesz znaleźć tutaj.

Jak rozwiązać ten problem

Zastąp synchroniczne operacje we/wy operacjami asynchronicznymi. Dzięki temu bieżący wątek zostanie zwolniony, aby kontynuować istotną pracę, a nie zablokowany oraz pomoże poprawić wykorzystanie zasobów obliczeniowych. Asynchroniczne wykonywanie operacji we/wy jest szczególnie wydajne podczas obsługi nieoczekiwanych skoków żądań z aplikacji klienckich.

Wiele biblioteki udostępnia zarówno synchroniczną, jak i asynchroniczną wersję metod. Jeśli to możliwe, używaj wersji asynchronicznej. Oto wersja asynchroniczna poprzedniego przykładu, która przekazuje plik do magazynu obiektów blob platformy Azure.

var blobClient = storageAccount.CreateCloudBlobClient();
var container = blobClient.GetContainerReference("uploadedfiles");

await container.CreateIfNotExistsAsync();

var blockBlob = container.GetBlockBlobReference("myblob");

// Create or overwrite the "myblob" blob with contents from a local file.
using (var fileStream = File.OpenRead(HostingEnvironment.MapPath("~/FileToUpload.txt")))
{
    await blockBlob.UploadFromStreamAsync(fileStream);
}

Operator await zwraca sterowanie do środowiska wywołującego podczas wykonywania operacji asynchronicznej. Kod po tej instrukcji działa jako kontynuacja, która uruchamia się po ukończeniu operacji asynchronicznej.

Dobrze zaprojektowana usługa również powinna udostępniać operacje asynchroniczne. Oto asynchroniczna wersja usługi internetowej, która zwraca profile użytkowników. Metoda GetUserProfileAsync zależy od posiadania asynchronicznej wersji usługi profilu użytkownika.

public interface IUserProfileService
{
    Task<UserProfile> GetUserProfileAsync();
}

public class AsyncController : ApiController
{
    private readonly IUserProfileService _userProfileService;

    public AsyncController()
    {
        _userProfileService = new FakeUserProfileService();
    }

    // This is a synchronous method that calls the Task based GetUserProfileAsync method.
    public Task<UserProfile> GetUserProfileAsync()
    {
        return _userProfileService.GetUserProfileAsync();
    }
}

Dla bibliotek, które nie udostępniają asynchronicznych wersji operacji, może istnieć możliwość tworzenia asynchronicznych otok wokół wybranych metod synchronicznych. Należy zachować ostrożność podczas postępowania zgodnie z tym podejściem. Chociaż może to poprawić czas odpowiedzi w wątku, który wywołuje asynchroniczną otokę, w rzeczywistości wykorzystuje więcej zasobów. Może zostać utworzony dodatkowy wątek i istnieje narzut związany z synchronizacją pracy wykonywanej przez ten wątek. Niektóre wady i zalety zostały omówione w tym wpisie w blogu: Should I expose asynchronous wrappers for synchronous methods? (Czy mam ujawniać asynchroniczne otoki synchronicznych metod?)

Oto przykład asynchronicznej otoki synchronicznej metody.

// Asynchronous wrapper around synchronous library method
private async Task<int> LibraryIOOperationAsync()
{
    return await Task.Run(() => LibraryIOOperation());
}

Teraz kod wywołujący może poczekać na otokę:

// Invoke the asynchronous wrapper using a task
await LibraryIOOperationAsync();

Kwestie wymagające rozważenia

  • Operacje we/wy, które powinny być bardzo krótkotrwałe i prawdopodobnie nie powinny powodować rywalizacji, mogą działać wydajniej niż operacje synchroniczne. Przykładem może być odczytywanie małych plików na dysku SSD. Narzut na wysyłanie zadania do innego wątku i synchronizację z tym wątkiem po ukończeniu zadania może przeważyć zalety asynchronicznych operacji we/wy. Jednak te przypadki są stosunkowo rzadkie i większość operacji we/wy powinna być wykonywana asynchronicznie.

  • Poprawianie wydajności operacji we/wy może spowodować, że inne składniki systemu staną się wąskimi gardłami. Na przykład odblokowywanie wątków może spowodować zwiększenie liczby równoczesnych żądań do udostępnionych zasobów, prowadząc z kolei do wyczerpania zasobów lub ograniczenia przepustowości. Jeśli stanie się to problemem, może być konieczne przeskalowanie w poziomie liczby serwerów internetowych lub magazynów danych partycji, aby zmniejszyć rywalizację.

Jak wykryć problem

Użytkownikom może wydawać się, że aplikacja okresowo nie odpowiada. Aplikacja może zakończyć się niepowodzeniem z powodu wyjątków przekroczenia limitu czasu. Te błędy mogą także zwracać błędy HTTP 500 (wewnętrzny serwer). Przychodzące żądania klientów mogą być blokowane na serwerze do momentu aż wątek stanie się dostępny, co powoduje nadmierne wydłużenie kolejki żądań, ujawniające się jako błędy HTTP 503 (Usługa niedostępna).

Możesz wykonać następujące kroki, aby ułatwić zidentyfikowanie problemu:

  1. Monitoruj system produkcyjny i ustal, czy zablokowane wątki robocze ograniczają przepływność.

  2. Jeśli żądania są blokowane z powodu braku wątków, przejrzyj aplikację w celu określenia, które operacje mogą synchronicznie wykonywać operacje we/wy.

  3. Przeprowadź kontrolowane testy obciążenia każdej operacji, która wykonuje synchroniczne operacje we/wy, aby dowiedzieć się, czy te operacje mają wpływ na wydajność systemu.

Przykładowa diagnostyka

W poniższych sekcjach zastosowano te kroki do opisanej wcześniej przykładowej aplikacji.

Monitorowanie wydajności serwera internetowego

Dla aplikacji internetowych platformy Azure i ról internetowych warto monitorować wydajności serwera internetowego usług IIS. W szczególności należy zwrócić uwagę na długość kolejki żądań w celu ustalenia, czy żądania są blokowane w oczekiwaniu na dostępne wątki w okresach dużej aktywności. Te informacje można zebrać, włączając diagnostykę platformy Azure. Aby uzyskać więcej informacji, zobacz:

Zapewnij instrumentację aplikacji, aby zobaczyć, jak żądania są obsługiwane, gdy zostały zaakceptowane. Śledzenie przepływu żądania może pomóc ustalić, czy wykonuje ono wolnodziałające wywołania i blokuje bieżący wątek. Profilowaniem wątku można również wyróżnić żądania, które są blokowane.

Test obciążeniowy aplikacji

Poniższy wykres ukazuje wydajność przedstawionej wcześniej synchronicznej metody GetUserProfile przy różnych obciążeniach do 4000 równoczesnych użytkowników. Aplikacja jest aplikacją ASP.NET działającą w internetowej roli usługi w chmurze platformy Azure.

Performance chart for the sample application performing synchronous I/O operations

Operacja synchroniczna ma na stałe zakodowany stan uśpienia na 2 sekundy, aby zasymulować synchroniczną operację we/wy, więc minimalny czas odpowiedzi wynosi nieco ponad 2 sekundy. Gdy obciążenie osiągnie około 2500 równoczesnych użytkowników, średni czas odpowiedzi osiągnie część płaską, chociaż wolumin żądań na sekundę nadal rośnie. Należy pamiętać, że skala dla tych dwóch miar jest logarytmiczna. Liczba żądań na sekundę podwaja się między tym punktem i końcem testu.

W przypadku izolacji nie jest koniecznie oczyszczenie z tego testu bez względu na to, czy synchroniczna operacja we/wy jest problemem. Przy większych obciążeniach aplikacja może osiągnąć punkt krytyczny, w którym serwer internetowy nie może już przetwarzać żądań w odpowiednim czasie, powodując, że aplikacje klienckie odbierają wyjątki przekroczenia limitu czasu.

Przychodzące żądania są umieszczane w kolejce przez serwer internetowy usług IIS i przekazywane do wątku działającego w puli wątków programu ASP.NET. Ponieważ każda operacja wykonuje operacje we/wy synchronicznie, wątek jest blokowany do chwili zakończenia operacji. W miarę zwiększania obciążenia w końcu wszystkie wątki programu ASP.NET w puli wątków zostaną przydzielone i zablokowane. W tym momencie wszelkie dalsze przychodzące żądania muszą czekać w kolejce na dostępny wątek. W miarę zwiększania długości kolejki żądania zaczynają przekraczać limit czasu.

Implementowanie rozwiązania i weryfikowanie wyniku

Następny wykres przedstawia wyniki testów obciążeniowych wersji asynchronicznej kodu.

Performance chart for the sample application performing asynchronous I/O operations

Przepływność jest znacznie większa. W tym samym czasie, co poprzedni test, system pomyślnie obsługuje prawie dziesięciokrotny wzrost przepływności mierzony w żądaniach na sekundę. Ponadto średni czas odpowiedzi jest względnie stały i pozostaje około 25 razy mniejszy niż w poprzednim teście.