Asynchrone wrappers voor synchrone methoden

Wanneer u een synchrone methode in een bibliotheek hebt, is het misschien verleidelijk om een asynchrone tegenhanger aan te bieden door deze in Task.Run te verpakken.

public T Foo() { /* synchronous work */ }

// Don't do this in a library:
public Task<T> FooAsync()
{
    return Task.Run(() => Foo());
}

In dit artikel wordt uitgelegd waarom deze benadering bijna altijd verkeerd is voor bibliotheken en hoe u nadenkt over de compromissen.

Schaalbaarheid versus offloading

Asynchrone programmering biedt twee verschillende voordelen:

  • Schaalbaarheid : verminder het resourceverbruik door threads te verwijderen tijdens I/O-wachttijden.
  • Offloading : verplaats werk naar een andere thread om de reactiesnelheid te behouden (bijvoorbeeld om een UI-thread vrij te houden) of parallelle uitvoering te bereiken.

Deze voordelen vereisen verschillende benaderingen. Het kritieke onderscheid: het verpakken van een synchrone methode in Task.Run helpt met het overdragen van taken, maar doet niets voor schaalbaarheid.

Waarom Task.Run de schaalbaarheid niet verbetert

Een echt asynchrone implementatie vermindert het aantal threads dat wordt gebruikt tijdens een langdurige bewerking. Een Task.Run wrapper blokkeert nog steeds een thread. Hiermee wordt de blokkering van de ene thread naar de andere verplaatst:

public static class TimerExampleWrong
{
    public static Task SleepAsync(int millisecondsTimeout)
    {
        return Task.Run(() => Thread.Sleep(millisecondsTimeout));
    }
}
Public Module TimerExampleWrong
    Public Function SleepAsync(millisecondsTimeout As Integer) As Task
        Return Task.Run(Sub() Thread.Sleep(millisecondsTimeout))
    End Function
End Module

Vergelijk deze benadering met een echt asynchrone implementatie die geen threads verbruikt tijdens het wachten:

public static class TimerExampleRight
{
    public static Task SleepAsync(int millisecondsTimeout)
    {
        var tcs = new TaskCompletionSource<bool>();
        var timer = new Timer(
            _ => tcs.TrySetResult(true), null, millisecondsTimeout, Timeout.Infinite);

        tcs.Task.ContinueWith(
            _ => timer.Dispose(), TaskScheduler.Default);

        return tcs.Task;
    }
}
Public Module TimerExampleRight
    Public Function SleepAsync(millisecondsTimeout As Integer) As Task
        Dim tcs As New TaskCompletionSource(Of Boolean)()
        Dim tmr As New Timer(
            Sub(state) tcs.TrySetResult(True), Nothing, millisecondsTimeout, Timeout.Infinite)

        tcs.Task.ContinueWith(
            Sub(t) tmr.Dispose(), TaskScheduler.Default)

        Return tcs.Task
    End Function
End Module

Beide implementaties zijn voltooid na de opgegeven vertraging, maar de tweede implementatie blokkeert geen thread tijdens het wachten. Voor servertoepassingen die veel gelijktijdige aanvragen verwerken, heeft dat verschil rechtstreeks invloed op het aantal aanvragen dat een server tegelijkertijd kan verwerken.

Het uitladen is de verantwoordelijkheid van de consument

Synchrone aanroepen in Task.Run verpakken is handig om werk van een gebruikersinterface-thread te verplaatsen. De consument, niet de bibliotheek, moet echter deze omsluiting afhandelen.

public static class UIOffloadExample
{
    public static int ComputeIntensive(int input)
    {
        int result = 0;
        for (int i = 0; i < input; i++)
        {
            result += i;
        }
        return result;
    }

    public static async Task ConsumeFromUIThreadAsync()
    {
        int result = await Task.Run(() => ComputeIntensive(10_000));
        Console.WriteLine($"Result: {result}");
    }
}
Public Module UIOffloadExample
    Public Function ComputeIntensive(input As Integer) As Integer
        Dim result As Integer = 0
        For i As Integer = 0 To input - 1
            result += i
        Next
        Return result
    End Function

    Public Async Function ConsumeFromUIThreadAsync() As Task
        Dim result As Integer = Await Task.Run(Function() ComputeIntensive(10_000))
        Console.WriteLine($"Result: {result}")
    End Function
End Module

De consument kent de context: of ze zich in een UI-thread bevinden, hoeveel granulariteit ze nodig hebben en of offloading waarde toevoegt. De bibliotheek doet het niet.

Waarom bibliotheken geen asynchrone synchronisatie-wrappers beschikbaar moeten maken

Wanneer een bibliotheek alleen de synchrone methode (en niet een asynchrone wrapper) beschikbaar maakt, profiteren consumenten op verschillende manieren:

  • Verminderd API-oppervlak: minder methoden om te leren, te testen en te onderhouden.
  • Geen misleidende schaalbaarheids verwachtingen: gebruikers weten dat alleen de methoden die als asynchroon worden weergegeven, daadwerkelijk schaalbaarheidsvoordelen bieden.
  • Consumentencontrole: bellers kiezen of en hoe ze het offloaden uitvoeren, op het juiste niveau van granulariteit. Een servertoepassing met hoge doorvoer kan de synchrone methode direct aanroepen, waardoor onnodige overhead van Task.Run wordt vermeden.
  • Betere prestaties: Asynchrone wrappers voegen overhead toe via toewijzingen, contextswitches en threadpool-scheduling. Voor fijnmazige bewerkingen kan die overhead aanzienlijk zijn.

Uitzonderingen op de regel

Sommige basisklassen maken asynchrone methoden beschikbaar, zodat afgeleide klassen deze kunnen overschrijven met echt asynchrone implementaties. De basisklasse biedt een async-over-sync-standaard.

Bijvoorbeeld stelt StreamReadAsync en WriteAsync bloot. De basis-implementaties verpakken de synchrone Read en Write methoden. Afgeleide klassen zoals FileStream en NetworkStream overschrijven deze methoden met asynchrone I/O-implementaties die echte schaalbaarheidsvoordelen bieden.

Op dezelfde manier biedt TextReaderReadToEndAsync de basisklasse als een wrapper, en StreamReader overschrijft deze met een echt asynchrone implementatie die intern ReadAsync aanroept.

Deze uitzonderingen zijn geldig omdat:

  • Het patroon is ontworpen voor polymorfisme. Gebruikers communiceren met het basistype.
  • Afgeleide typen bieden echt asynchrone overrides.

Richtlijn

Maak asynchrone methoden alleen beschikbaar vanuit een bibliotheek wanneer de implementatie echte schaalbaarheidsvoordelen biedt ten opzichte van de synchrone tegenhanger. Maak geen asynchrone methoden beschikbaar voor offloading. Laat die keuze aan de consument over.

Zie ook