Synchrone wrappers voor asynchrone methoden

Wanneer een bibliotheek alleen asynchrone API's beschikbaar maakt, verpakken consumenten deze soms in synchrone aanroepen om te voldoen aan een synchrone interface of contract. Dit patroon 'sync-over-async' kan eenvoudig lijken, maar het is een veelvoorkomende bron van impasses en prestatieproblemen.

Basisomwikkelingspatronen

Een synchrone wrapper rond een Task-gebaseerde Asynchrone Patroonmethode (TAP) benadert de Result-eigenschap van de taak, waardoor de aanroepende thread geblokkeerd raakt.

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

Deze benadering ziet er eenvoudig uit, maar kan ernstige problemen veroorzaken, afhankelijk van de omgeving waarin deze wordt uitgevoerd.

Deadlocks met enkelvoudige contexten

Het gevaarlijkste scenario treedt op wanneer u een synchrone wrapper aanroept vanuit een thread die enkelvoudig is SynchronizationContext. Dit scenario is doorgaans een UI-thread in WPF-, Windows Forms- of MAUI-toepassingen.

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

Dit gebeurt stap voor stap:

  1. De UI-thread roept Delay aan, dat op zijn beurt DelayAsync(milliseconds).Wait() aanroept.
  2. DelayAsync wordt synchroon uitgevoerd totdat het bereikt await Task.Delay(milliseconds).
  3. Omdat de vertraging nog niet is voltooid, await wordt de huidige SynchronizationContext vastgelegd en onderbroken. DelayAsync retourneert een Task aan de aanroeper.
  4. De UI-thread blokkeert in .Wait(), terwijl het wacht tot die taak is voltooid.
  5. Wanneer de vertraging is voltooid, moet de uitvoering doorgaan op de oorspronkelijke SynchronizationContext-thread, wat de UI-thread is.
  6. De UI-thread kan de voortzetting niet verwerken omdat deze is geblokkeerd in .Wait().
  7. Impasse.

Belangrijk

Het succes of falen van sync-over-async code is afhankelijk van de omgeving waarin het wordt uitgevoerd. Code die in een console-app werkt, kan een impasse opleveren voor een UI-thread of in ASP.NET (op .NET Framework). Deze omgevingsafhankelijkheid is een belangrijke reden om te vermijden dat synchrone wrappers blootgesteld worden.

Uitputting van threadpool

Impasses zijn niet beperkt tot UI-threads. Als een asynchrone methode afhankelijk is van de threadpool om het werk te voltooien, bijvoorbeeld door een laatste verwerkingsstap in de wachtrij te plaatsen, kan het blokkeren van veel draadpools met synchrone wrappers leiden tot het leegtrekken van de pool.

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

In dit scenario:

  1. Veel thread pool threads roepen Foo aan, dat in .Result blokkeert.
  2. Elke asynchrone bewerking voltooit de I/O en heeft een thread pool nodig om de voltooingscallback uit te voeren.
  3. Omdat geblokkeerde aanroepen beschikbare werkrolthreads in beslag nemen, kunnen voltooiingen lang wachten tot een thread beschikbaar is.
  4. Moderne .NET kan na verloop van tijd meer draadpoolthreads toevoegen, maar de toepassing kan nog steeds ernstige threadpool-uitputting, slechte doorvoer, lange vertragingen of een ogenschijnlijke vastloper ondervinden.

Dit patroon beïnvloedde HttpWebRequest.GetResponse in .NET Framework 1.x, waarbij de synchrone methode werd geïmplementeerd als een wrapper rond de asynchrone BeginGetResponse/EndGetResponse.

Richtlijn: Vermijd het blootstellen van synchrone wrappers

Maak geen synchrone methode beschikbaar die een asynchrone implementatie verpakt. Laat in plaats daarvan de beslissing of er moet worden geblokkeerd aan de gebruikers over. De consument kent hun draadomgeving en kan een geïnformeerde keuze maken.

Als u merkt dat u een asynchrone methode synchroon moet aanroepen, bedenk dan eerst of u de code kunt herstructureren, zodat deze volledig asynchroon is. Herstructureren is vaak de betere lange-termijnoplossing.

Risicobeperkingsstrategieën wanneer sync-over-async onvermijdelijk is

Soms is sync-over-async echt onvermijdelijk. Het is bijvoorbeeld onvermijdelijk wanneer u een interface implementeert waarvoor een synchrone methode is vereist en de enige beschikbare implementatie asynchroon is. In dergelijke gevallen past u de volgende strategieën toe om het risico te verminderen.

Gebruiken ConfigureAwait(false) in de asynchrone implementatie

Als u de asynchrone methode beheert, gebruikt Task.ConfigureAwait met false op elke await om te voorkomen dat de voortzetting wordt gemarshald naar het oorspronkelijke 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

Als auteur van een bibliotheek, gebruik ConfigureAwait(false) op alle await-uitdrukkingen, tenzij uw code specifiek moet worden hervat in de vastgelegde context. Het gebruik van ConfigureAwait(false) is een best practice voor prestaties en helpt deadlocks te voorkomen wanneer consumenten blokkeren.

Overdragen naar de threadpool

Als u geen controle hebt over de asynchrone implementatie (en deze mogelijk niet gebruikt ConfigureAwait(false)), moet u de aanroep naar de threadgroep offloaden. De threadpool heeft geen SynchronizationContext, dus 'await' zal niet proberen terug te keren naar een geblokkeerde thread.

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

Testen in meerdere omgevingen

Als u een synchrone wrapper moet verzenden, test u deze vanuit:

  • Een UI-thread (WPF, Windows Forms).
  • De threadpool staat onder belasting.
  • De threadpool met een laag maximumaantal threads.
  • Een consoletoepassing.

Gedrag dat in de ene omgeving werkt, kan in een andere omgeving tot een deadlock leiden.

Zie ook