Asynchrone Wrapper für synchrone Methoden

Wenn Sie über eine synchrone Methode in einer Bibliothek verfügen, sind Sie möglicherweise versucht, ein asynchrones Gegenstück verfügbar zu machen, das sie umschließt:Task.Run

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

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

In diesem Artikel wird erläutert, warum dieser Ansatz für Bibliotheken fast immer falsch ist und wie man über die Kompromisse nachdenken kann.

Skalierbarkeit und Offloading

Die asynchrone Programmierung bietet zwei unterschiedliche Vorteile:

  • Skalierbarkeit – Verringern Sie den Ressourcenverbrauch, indem Sie Threads während der E/A-Wartezeit freigeben.
  • Auslagern – Verschieben Sie Arbeit in einen anderen Thread, um die Reaktionsfähigkeit aufrechtzuerhalten (z. B. einen UI-Thread frei zu halten) oder eine Parallelität zu erzielen.

Diese Vorteile erfordern unterschiedliche Ansätze. Der kritische Unterschied: Das Umschließen einer synchronen Methode Task.Run hilft beim Auslagern, trägt jedoch nichts zur Skalierbarkeit bei.

Warum Task.Run verbessert sich die Skalierbarkeit nicht

Eine wirklich asynchrone Implementierung reduziert die Anzahl der Threads, die während eines lang ausgeführten Vorgangs verbraucht werden. Ein Task.Run Wrapper blockiert weiterhin einen Thread – er verschiebt lediglich die Blockierung von einem Thread in einen anderen:

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

Vergleichen Sie diesen Ansatz mit einer wirklich asynchronen Implementierung, die beim Warten keine Threads verbraucht:

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 Implementierungen werden nach der angegebenen Verzögerung abgeschlossen, die zweite Implementierung blockiert jedoch keinen Thread beim Warten. Bei Serveranwendungen, die viele gleichzeitige Anforderungen verarbeiten, wirkt sich dieser Unterschied direkt darauf aus, wie viele Anforderungen ein Server gleichzeitig verarbeiten kann.

Das Ausladen liegt in der Verantwortung des Verbrauchers.

Das Umschließen synchroner Aufrufe in Task.Run ist nützlich, um Arbeit aus einem UI-Thread auszulagern. Der Consumer, nicht die Bibliothek, sollte jedoch diese Verpackung behandeln.

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

Der Verbraucher kennt deren Kontext: ob sie sich in einem UI-Thread befinden, wie viel Granularität sie benötigen und ob das Auslagern Mehrwert bringt. Die Bibliothek tut es nicht.

Warum Bibliotheken async-over-sync wrapper nicht verfügbar machen sollten

Wenn eine Bibliothek nur die synchrone Methode (und nicht ein asynchroner Wrapper) verfügbar macht, profitieren Verbraucher auf verschiedene Arten:

  • Reduzierter API-Oberflächenbereich: Weniger Methoden zum Erlernen, Testen und Warten.
  • Keine irreführenden Skalierbarkeitserwartungen: Benutzer wissen, dass nur die Methoden, die als asynchron verfügbar gemacht werden, Skalierbarkeitsvorteile bieten.
  • Verbrauchersteuerung: Anrufer wählen aus, ob und wie sie auslagern, auf der geeigneten Granularitätsebene. Eine Serveranwendung mit hohem Durchsatz kann die synchrone Methode direkt aufrufen und unnötigen Aufwand vermeiden Task.Run.
  • Bessere Leistung: Asynchrone Wrapper fügen Mehraufwand durch Zuordnungen, Kontextschalter und Threadpoolplanung hinzu. Bei feinkörnigen Vorgängen kann dieser Aufwand erheblich sein.

Ausnahmen von der Regel

Einige Basisklassen machen asynchrone Methoden verfügbar, sodass abgeleitete Klassen sie mit wirklich asynchronen Implementierungen überschreiben können. Die Basisklasse bietet standardmäßig eine async-over-sync Implementierung.

Beispielsweise stellt StreamReadAsync und WriteAsync bereit. Die Basisimplementierungen umschließen die synchronen Read und Write Methoden. Abgeleitete Klassen wie FileStream und NetworkStream überschreiben diese Methoden mit asynchronen E/A-Implementierungen, die echte Skalierbarkeitsvorteile bieten.

Ähnlich stellt TextReader in der Basisklasse als Wrapper bereit, und StreamReader überschreibt es mit einer wirklich asynchronen Implementierung, die intern ReadAsync aufruft.

Diese Ausnahmen sind gültig, da:

  • Das Muster wurde für Polymorphismus entwickelt. Anrufer interagieren mit dem Basistyp.
  • Abgeleitete Typen bieten echte asynchrone Überschreibungen an.

Vorgabe

Machen Sie asynchrone Methoden aus einer Bibliothek nur verfügbar, wenn die Implementierung echte Skalierbarkeitsvorteile gegenüber dem synchronen Gegenstück bietet. Machen Sie keine asynchronen Methoden nur zum Entladen verfügbar. Lassen Sie diese Wahl dem Verbraucher überlassen.

Siehe auch