Synchroniczne opakowania dla metod asynchronicznych

Gdy biblioteka uwidacznia tylko asynchroniczne interfejsy API, konsumenci czasami opakowują je w synchroniczne wywołania, aby spełnić wymagania synchronicznego interfejsu lub kontraktu. Ten wzorzec zwany "sync-over-async" może wydawać się prosty, ale jest to typowe źródło zakleszczeń i problemów z wydajnością.

Podstawowe wzorce zawijania

Synchroniczna obudowa wokół metody Asynchronicznego Wzorca opartego na zadaniach (TAP) uzyskuje dostęp do właściwości zadania Result, która blokuje wątek wywołujący:

public class TapWrapper
{
    public static int Foo(Func<Task<int>> fooAsync)
    {
        return fooAsync().Result;
    }
}
Public Module TapWrapper
    Public Function Foo(fooAsync As Func(Of Task(Of Integer))) As Integer
        Return fooAsync().Result
    End Function
End Module

Takie podejście wygląda prosto, ale może powodować poważne problemy w zależności od środowiska, w którym działa.

Zakleszczenia w kontekstach jednowątkowych

Najbardziej niebezpiecznym scenariuszem jest wywołanie synchronicznej otoki z wątku, który ma pojedynczy wątek SynchronizationContext. Ten scenariusz jest zazwyczaj wątkiem interfejsu użytkownika w aplikacjach WPF, Windows Forms lub MAUI.

public static class DeadlockExample
{
    private static void Delay(int milliseconds)
    {
        DelayAsync(milliseconds).Wait();
    }

    private static async Task DelayAsync(int milliseconds)
    {
        await Task.Delay(milliseconds);
    }
}
Public Module DeadlockExample
    Private Sub Delay(milliseconds As Integer)
        DelayAsync(milliseconds).Wait()
    End Sub

    Private Async Function DelayAsync(milliseconds As Integer) As Task
        Await Task.Delay(milliseconds)
    End Function
End Module

Oto, co dzieje się krok po kroku:

  1. Wątek interfejsu użytkownika wywołuje metodę Delay, która wywołuje metodę DelayAsync(milliseconds).Wait().
  2. DelayAsync działa synchronicznie, dopóki nie osiągnie await Task.Delay(milliseconds).
  3. Ponieważ opóźnienie nie zostało jeszcze ukończone, await przechwytuje bieżący SynchronizationContext i zawiesza się. DelayAsync zwraca Task do wywołującego.
  4. Wątek interfejsu użytkownika blokuje się w .Wait(), czekając na ukończenie tego zadania.
  5. Po zakończeniu opóźnienia kontynuacja musi zostać uruchomiona w oryginalnym SynchronizationContext wątku interfejsu użytkownika.
  6. Wątek interfejsu użytkownika nie może przetworzyć kontynuacji, ponieważ jest zablokowany w .Wait().
  7. Zakleszczenie.

Ważna

Powodzenie lub niepowodzenie kodu synchronizacji nad asynchronicznym zależy od środowiska, w którym jest uruchamiany. Kod, który działa w aplikacji konsolowej, może spowodować zakleszczenie w wątku interfejsu użytkownika lub w aplikacji ASP.NET (na platformie .NET Framework). Ta zależność środowiskowa jest główną przyczyną, aby unikać eksponowania synchronicznych wrapperów.

Wyczerpanie puli wątków

Zakleszczenia nie ograniczają się tylko do wątków interfejsu użytkownika. Jeśli metoda asynchroniczna polega na puli wątków, aby ukończyć swoją pracę, na przykład kolejkowaniem końcowego kroku przetwarzania, blokowanie wielu wątków z użyciem synchronicznych obudów może zagłodzić pulę:

public static class ThreadPoolDeadlockExample
{
    public static int Foo(Func<Task<int>> fooAsync)
    {
        return fooAsync().Result;
    }

    public static async Task DemonstrateDeadlockRiskAsync()
    {
        var tasks = Enumerable.Range(0, 25)
            .Select(_ => Task.Run(() => Foo(() => SomeIOOperationAsync())));
        await Task.WhenAll(tasks);
    }

    private static async Task<int> SomeIOOperationAsync()
    {
        await Task.Delay(100);
        return 42;
    }
}

W tym scenariuszu:

  1. Wiele wątków w puli wątków wywołuje Foo, który blokuje w .Result.
  2. Każda operacja asynchroniczna kończy operację we/wy i potrzebuje wątku z puli wątków, aby uruchomić jego funkcję zwrotną po zakończeniu.
  3. Ponieważ zablokowane wywołania zajmują dostępne wątki robocze, zakończenia mogą czekać przez dłuższy czas na udostępnienie wątku.
  4. Nowoczesny system .NET może z czasem dodawać więcej wątków do puli, ale aplikacja nadal może cierpieć na poważne niedobory puli wątków, niską wydajność, długie opóźnienia lub pozorne zawieszenie się.

Ten wzorzec wpłynął na HttpWebRequest.GetResponse w .NET Framework 1.x, gdzie metoda synchroniczna została zaimplementowana jako nakładka wokół asynchronicznego BeginGetResponse/EndGetResponse.

Wytyczne: Unikaj ujawniania synchronicznych opakowań

Nie udostępniaj metody synchronicznej, która opakowuje implementację asynchroniczną. Zamiast tego pozostaw decyzję, czy zablokować konsumentowi. Konsument, znając swoje środowisko wątkowe, może dokonać świadomego wyboru.

Jeśli okaże się, że trzeba wywołać metodę asynchroniczną synchronicznie, rozważ najpierw, czy możesz zrestrukturyzować kod jako "asynchroniczny aż w dół". Refaktoryzacja jest często lepszym rozwiązaniem długoterminowym.

Strategie łagodzenia, gdy synchronizacja z użyciem mechanizmów asynchronicznych jest nieunikniona

Czasami synchronizacja za pośrednictwem async jest naprawdę nieunikniona. Na przykład jest to nieuniknione podczas implementowania interfejsu wymagającego metody synchronicznej, a jedyną dostępną implementacją jest asynchroniczna. W takich przypadkach zastosuj następujące strategie, aby zmniejszyć ryzyko.

Użyj ConfigureAwait(false) w implementacji asynchronicznej

Jeśli kontrolujesz metodę asynchroniczną, użyj Task.ConfigureAwait z false na każdym await, aby uniemożliwić kontynuację przekazywania do oryginalnego SynchronizationContext.

public static class ConfigureAwaitMitigation
{
    public static async Task<int> LibraryMethodAsync()
    {
        await Task.Delay(100).ConfigureAwait(false);
        return 42;
    }

    public static int Sync()
    {
        return LibraryMethodAsync().GetAwaiter().GetResult();
    }
}
Public Module ConfigureAwaitMitigation
    Public Async Function LibraryMethodAsync() As Task(Of Integer)
        Await Task.Delay(100).ConfigureAwait(False)
        Return 42
    End Function

    Public Function Sync() As Integer
        Return LibraryMethodAsync().Result
    End Function
End Module

Jako autor biblioteki, należy używać ConfigureAwait(false) we wszystkich użyciach 'await', chyba że kod musi zostać wznowiony w przechwyconym kontekście. Użycie ConfigureAwait(false) jest najlepszą praktyką w zakresie wydajności i pomaga unikać deadlocków, gdy konsumenci się blokują.

Przekazywanie zadania do puli wątków

Jeśli nie kontrolujesz asynchronicznej implementacji (która może nie używać ConfigureAwait(false)), przenieś wywołanie do puli wątków. Pula wątków nie ma elementu SynchronizationContext, więc oczekiwanie nie spróbuje przeprowadzić marshalingu z powrotem do zablokowanego wątku:

public int Sync()
{
    return Task.Run(() => Library.FooAsync()).Result;
}
Public Function Sync() As Integer
    Return Task.Run(Function() Library.FooAsync()).Result
End Function

Testowanie w wielu środowiskach

Jeśli musisz dostarczyć synchroniczne opakowanie, przetestuj je z:

  • Wątek interfejsu użytkownika (WPF, Windows Forms).
  • Pula wątków jest obciążona.
  • Pula wątków z małą maksymalną liczbą wątków.
  • Aplikacja konsolowa.

Zachowanie, które działa w jednym środowisku, może utknąć w innym.

Zobacz także