Synkrona omslutningar för asynkrona metoder

När ett bibliotek endast exponerar asynkrona API:er omsluter konsumenterna dem ibland i synkrona anrop för att uppfylla ett synkront gränssnitt eller kontrakt. Det här "sync-over-async"-mönstret kan verka enkelt, men det är en vanlig källa till dödlägen och prestandaproblem.

Grundläggande omslutningsmönster

En synkron omslutning av en uppgiftsbaserad Task-based Asynchronous Pattern (TAP)-metod kommer åt uppgiftens Result-egenskap, vilket blockerar den anropande tråden.

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

Den här metoden ser enkel ut, men den kan orsaka allvarliga problem beroende på vilken miljö den körs i.

Dödlägen med entrådade kontexter

Det farligaste scenariot inträffar när du anropar en synkron omslutning från en tråd som har en enkeltrådad SynchronizationContext. Det här scenariot är vanligtvis en användargränssnittstråd i WPF-, Windows Forms- eller MAUI-program.

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

Så här händer steg för steg:

  1. Användargränssnittstråden anropar Delay, som anropar DelayAsync(milliseconds).Wait().
  2. DelayAsync körs synkront tills den når await Task.Delay(milliseconds).
  3. Eftersom fördröjningen inte är klar ännu, await registrerar den aktuella SynchronizationContext och pausar. DelayAsync returnerar en Task till anroparen.
  4. UI-tråden blockeras i .Wait()och väntar på att uppgiften ska slutföras.
  5. När fördröjningen är klar behöver fortsättningen köras på den ursprungliga SynchronizationContext, som är användargränssnittstråden.
  6. Användargränssnittstråden kan inte bearbeta fortsättningen eftersom den är blockerad i .Wait().
  7. Dödläge.

Viktigt!

Om sync-over-async-koden lyckas eller misslyckas beror det på vilken miljö den körs i. Kod som fungerar i en konsolapp kan vara låst i en användargränssnittstråd eller i ASP.NET (på .NET Framework). Det här miljöberoendet är en viktig orsak till att undvika att exponera synkrona omslutningar.

Trådpoolsöverbelastning

Dödlägen är inte begränsade till gränssnittstrådar. Om en asynkron metod är beroende av trådpoolen för att slutföra sitt arbete, till exempel genom att köa ett sista bearbetningssteg, kan blockering av många pooltrådar med synkrona omslutningar leda till resursbrist i poolen:

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;
    }
}

I det här scenariot:

  1. Många trådar i trådpoolen anropar Foo, som blockerar i .Result.
  2. Varje asynkron operation slutför sin I/O och behöver en tråd från trådpoolen för att köra sin återanropsfunktion.
  3. Eftersom blockerade anrop upptar tillgängliga arbetstrådar kan slutföranden vänta länge tills en tråd blir tillgänglig.
  4. Moderna .NET kan lägga till fler trådpooltrådar över tid, men programmet kan fortfarande drabbas av svår trådpoolssvält, dåligt dataflöde, långa fördröjningar eller en uppenbar hängning.

Det här mönstret påverkade HttpWebRequest.GetResponse i .NET Framework 1.x, där den synkrona metoden implementerades som omslutning runt den asynkrona BeginGetResponse/EndGetResponse.

Riktlinje: Undvik att exponera synkrona omslutningar

Exponera inte en synkron metod som omsluter en asynkron implementering. Lämna i stället beslutet om att blockera för konsumenten. Konsumenten känner till sin trådmiljö och kan göra ett välgrundat val.

Om du behöver anropa en asynkron metod synkront bör du först överväga om du kan omstrukturera koden så att den är "asynkron hela vägen nedåt". Refaktorisering är ofta den bättre långsiktiga lösningen.

Åtgärdsstrategier när synkronisering över asynkronisering inte kan undvikas

Ibland är det verkligen oundvikligt att använda synkronisering över asynkrona metoder. Det är till exempel oundvikligt när du implementerar ett gränssnitt som kräver en synkron metod och den enda tillgängliga implementeringen är asynkron. I dessa fall använder du följande strategier för att minska risken.

Använda ConfigureAwait(false) i asynkron implementering

Om du styr async-metoden, använd Task.ConfigureAwait med false vid varje await för att förhindra att fortsättningen återställs till den ursprungliga 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

Som biblioteksutvecklare, använd ConfigureAwait(false) på alla väntningar om inte din kod specifikt behöver återupptas i den sparade kontexten. Att använda ConfigureAwait(false) är bästa praxis för prestanda och hjälper till att förhindra dödlägen när konsumenter blockerar.

Avlasta till trådpoolen

Om du inte kontrollerar asynkron implementeringen (och den kanske inte använder ConfigureAwait(false)) läser du av anropet till trådpoolen. Trådpoolen har ingen SynchronizationContext, så inväntningen kommer inte att försöka hoppa tillbaka till en blockerad tråd:

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

Testa i flera miljöer

Om du måste skicka en synkron omslutning testar du den från:

  • En användargränssnittstråd (WPF, Windows Forms).
  • Trådpoolen under belastning.
  • Trådpoolen med ett lågt maximalt antal trådar.
  • Ett konsolprogram.

Beteende som fungerar i en miljö kan vara låst i en annan.

Se även