Implementieren des aufgabenbasierten asynchronen Entwurfsmusters

Sie können das aufgabenbasierte asynchrone Muster (Task-based Asynchronous Pattern, TAP) auf drei Arten implementieren: mit C# und den Visual Basic-Compilern in Visual Studio, manuell oder mit einer Kombination von Compilermethoden und manuellen Methoden. In den folgenden Abschnitten wird jede dieser Methoden ausführlich erörtert. Mit dem TAP-Muster können sowohl rechnergebundene als auch E/A-gebundene asynchrone Vorgänge implementiert werden. Im Abschnitt Workloads werden die einzelnen Vorgangstypen erläutert.

Generieren von TAP-Methoden

Verwenden der Compiler

Ab .NET Framework 4.5 gilt jede Methode, die mit dem Schlüsselwort async (Async in Visual Basic) attributiert ist, als asynchrone Methode, und die C#- und Visual Basic-Compiler führen die erforderlichen Transformationen aus, um die Methode mithilfe der TAP-Methode als asynchrone Methode zu implementieren. Eine asynchrone Methode sollte entweder System.Threading.Tasks.Task- oder ein System.Threading.Tasks.Task<TResult>-Objekt zurückgeben. Im letzteren Fall sollte der Text der Funktion einen TResult-Typ zurückgeben, und der Compiler stellt sicher, dass dieses Ergebnis durch das resultierende Taskobjekt verfügbar gemacht wird. Entsprechend werden alle Ausnahmen, die im Text der Methode nicht behandelt werden, zur Ausgabeaufgabe gemarshallt und führen dazu, dass die resultierende Aufgabe im Zustand TaskStatus.Faulted endet. Die Ausnahme dieser Regel ist, wenn eine OperationCanceledException (oder abgeleitete Typen) nicht behandelt wird. In diesem Fall enden die resultierenden Aufgaben im Zustand TaskStatus.Canceled.

Manuelles Generieren von TAP-Methoden

Sie können das TAP-Muster manuell implementieren, um eine bessere Kontrolle über die Implementierung zu haben. Der Compiler nutzt den öffentlichen Oberflächenbereich, der über den System.Threading.Tasks-Namespace verfügbar gemacht wird, und unterstützende Typen im System.Runtime.CompilerServices-Namespace. Wenn Sie das TAP selbst implementieren möchten, erstellen Sie ein TaskCompletionSource<TResult>-Objekt, führen Sie den asynchronen Vorgang aus, und wenn er abgeschlossen wurde, rufen Sie die Methode SetResult, SetException oder SetCanceled oder die Try-Version einer dieser Methoden auf. Wenn Sie eine TAP-Methode manuell implementieren, müssen Sie sicherstellen, dass die resultierende Aufgabe abgeschlossen wird, wenn der dargestellte asynchrone Vorgang abgeschlossen wird. Zum Beispiel:

public static Task<int> ReadTask(this Stream stream, byte[] buffer, int offset, int count, object state)
{
    var tcs = new TaskCompletionSource<int>();
    stream.BeginRead(buffer, offset, count, ar =>
    {
        try { tcs.SetResult(stream.EndRead(ar)); }
        catch (Exception exc) { tcs.SetException(exc); }
    }, state);
    return tcs.Task;
}
<Extension()>
Public Function ReadTask(stream As Stream, buffer() As Byte,
                         offset As Integer, count As Integer,
                         state As Object) As Task(Of Integer)
    Dim tcs As New TaskCompletionSource(Of Integer)()
    stream.BeginRead(buffer, offset, count, Sub(ar)
                                                Try
                                                    tcs.SetResult(stream.EndRead(ar))
                                                Catch exc As Exception
                                                    tcs.SetException(exc)
                                                End Try
                                            End Sub, state)
    Return tcs.Task
End Function

Hybrider Ansatz

Möglicherweise finden Sie es sinnvoll, das TAP-Muster manuell zu implementieren, die Kernlogik für die Implementierung jedoch an den Compiler zu delegieren. Sie sollen den hybriden Ansatz beispielsweise verwenden, wenn Sie Argumente außerhalb einer vom Compiler generierten asynchronen Methode überprüfen möchten, damit die Ausnahmen zum direkten Aufrufer der Methode überwechseln können, anstatt durch das Objekt System.Threading.Tasks.Task verfügbar gemacht zu werden:

public Task<int> MethodAsync(string input)
{
    if (input == null) throw new ArgumentNullException("input");
    return MethodAsyncInternal(input);
}

private async Task<int> MethodAsyncInternal(string input)
{

   // code that uses await goes here

   return value;
}
Public Function MethodAsync(input As String) As Task(Of Integer)
    If input Is Nothing Then Throw New ArgumentNullException("input")

    Return MethodAsyncInternal(input)
End Function

Private Async Function MethodAsyncInternal(input As String) As Task(Of Integer)

    ' code that uses await goes here

    return value
End Function

Eine solche Delegierung ist auch sinnvoll, wenn Sie eine Beschleunigungsoptimierung implementieren und eine zwischengespeicherte Aufgabe zurückgeben möchten.

Arbeitslasten

Sowohl rechnergebundene als auch E/A-gebundene asynchrone Vorgänge können als TAP-Methoden implementiert werden. Wenn sie jedoch aus einer Bibliothek öffentlich verfügbar gemacht werden, sollten TAP-Methoden nur für Arbeitslasten bereitgestellt werden, die E/A-gebundene Vorgänge enthalten (sie können auch Berechnungen enthalten, jedoch nicht ausschließlich). Wenn eine Methode ausschließlich rechnergebunden ist, sollte sie nur als synchrone Implementierung verfügbar gemacht werden. Der verwendende Code kann dann entscheiden, ob ein Aufruf dieser synchronen Methode durch einen Task umschlossen werden soll, um die Arbeit in einen anderen Thread auszulagern oder Parallelität zu erreichen. Wenn eine Methode dagegen E/A-gebunden ist, sollte sie ausschließlich als asynchrone Implementierung verfügbar gemacht werden.

Rechnergebundene Tasks

Die System.Threading.Tasks.Task-Klasse ist perfekt zur Darstellung von rechenintensiven Vorgängen geeignet. Standardmäßig nutzt sie spezielle Unterstützung innerhalb der ThreadPool-Klasse, um die effiziente Ausführung bereitzustellen, und bietet auch bedeutende Kontrolle darüber, wann, wo und wie asynchrone Berechnungen durchgeführt werden.

Sie können rechnergebundene Aufgaben auf die folgenden Arten generieren:

  • Verwenden Sie in .NET Framework 4.5 und höheren Versionen (einschließlich .NET Core und .NET 5 und höher) die statische Methode Task.Run als Verknüpfung zu TaskFactory.StartNew. Sie können Run verwenden, um problemlos eine rechnergebundene Aufgabe zu starten, die auf den Threadpool abzielt. Dies ist der bevorzugte Mechanismus für das Starten eines computegebundenen Tasks. Verwenden Sie StartNew nur dann direkt, wenn Sie eine präzisere Kontrolle über den Task haben möchten.

  • Verwenden Sie in .NET Framework 4 die TaskFactory.StartNew-Methode, die akzeptiert, dass ein Delegat (in der Regel ein Action<T>- oder ein Func<TResult>-Delegat) asynchron ausgeführt wird. Wenn Sie einen Action<T>-Delegaten bereitstellen, gibt die Methode ein System.Threading.Tasks.Task-Objekt zurück, das die asynchrone Ausführung dieses Delegaten darstellt. Wenn Sie einen Func<TResult>-Delegaten bereitstellen, gibt die Methode ein System.Threading.Tasks.Task<TResult>-Objekt zurück. Überladungen der StartNew-Methode akzeptieren ein Abbruchtoken (CancellationToken), Aufgabenerstellungsoptionen (TaskCreationOptions) und einen Aufgabenplaner (TaskScheduler), die alle eine präzisere Steuerung der Planung und Ausführung der Aufgabe ermöglichen. Eine Factoryinstanz für den aktuellen Aufgabenplaner ist als statische Eigenschaft (Factory) der Task-Klasse verfügbar, z. B. Task.Factory.StartNew(…).

  • Verwenden Sie die Konstruktoren des Typs Task und der Methode Start, wenn Sie die Aufgabe separat generieren und planen möchten. Öffentliche Methoden dürfen nur Aufgaben zurückgeben, die bereits gestartet wurden.

  • Verwenden Sie die Überladungen der Task.ContinueWith-Methode. Diese Methode erstellt eine neue Aufgabe, die geplant wird, wenn eine andere Aufgabe abgeschlossen wurde. Einige der ContinueWith-Überladungen akzeptieren ein Abbruchtoken, Fortsetzungsmöglichkeiten und einen Aufgabenplaner für eine bessere Steuerung der Planung und Ausführung der Fortsetzungsaufgabe.

  • Verwenden Sie die TaskFactory.ContinueWhenAll-Methode und die TaskFactory.ContinueWhenAny-Methode. Diese Methoden erstellen eine neue Aufgabe, die geplant wird, wenn eine oder alle Aufgaben in einem bereitgestellten Satz von Aufgaben abgeschlossen sind. Außerdem stellen diese Methoden Überladungen bereit, um die Planung und die Ausführung dieser Tasks zu steuern.

In rechnergebundenen Aufgaben kann das System die Ausführung einer geplanten Aufgabe verhindern, wenn es vor Start der Aufgabe eine Abbruchanforderung empfängt. Wenn Sie also ein Abbruchtoken (CancellationToken- Objekt) bereitstellen, können Sie dieses Token an den asynchronen Code übergeben, der das Token überwacht. Sie können das Token auch an eine der zuvor erwähnten Methoden wie StartNew oder Run bereitstellen, damit die Laufzeit der Task auch das Token überwachen kann.

Betrachten Sie beispielsweise eine asynchrone Methode, die ein Bild rendert. Der Text der Aufgabe kann das Abbruchtoken abrufen, sodass während des Renderns die Ausführung des Codes vorzeitig beendet werden kann, falls eine Abbruchanforderung empfangen wird. Zudem sollten Sie den Renderingvorgang verhindern, wenn die Abbruchsanforderung eintrifft, bevor das Rendering beginnt:

internal Task<Bitmap> RenderAsync(
              ImageData data, CancellationToken cancellationToken)
{
    return Task.Run(() =>
    {
        var bmp = new Bitmap(data.Width, data.Height);
        for(int y=0; y<data.Height; y++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            for(int x=0; x<data.Width; x++)
            {
                // render pixel [x,y] into bmp
            }
        }
        return bmp;
    }, cancellationToken);
}
Friend Function RenderAsync(data As ImageData, cancellationToken As _
                            CancellationToken) As Task(Of Bitmap)
    Return Task.Run(Function()
                        Dim bmp As New Bitmap(data.Width, data.Height)
                        For y As Integer = 0 to data.Height - 1
                            cancellationToken.ThrowIfCancellationRequested()
                            For x As Integer = 0 To data.Width - 1
                                ' render pixel [x,y] into bmp
                            Next
                        Next
                        Return bmp
                    End Function, cancellationToken)
End Function

Rechnergebundene Aufgaben werden im Zustand Canceled beendet, wenn mindestens eine der folgenden Bedingungen erfüllt ist:

  • Eine Abbruchanforderung geht über das CancellationToken-Objekt ein, das als Argument an die Erstellungsmethode bereitgestellt wird (beispielsweise, StartNew oder Run), bevor die Aufgabe zum Zustand Running übergeht.

  • Eine OperationCanceledException-Ausnahme wird im Text einer solchen Aufgabe nicht behandelt, diese Ausnahme enthält das gleiche CancellationToken, das an die Aufgabe übergeben wird und dieses Token stellt dar, dass der Abbruch angefordert wird.

Wenn im Text der Aufgabe eine weitere Ausnahme nicht behandelt wird, wird diese im Zustand Faulted beendet, und jeder Versuch, auf die Aufgabe zu warten oder auf ihr Ergebnis zuzugreifen, löst eine Ausnahme aus.

E/A-gebundene Tasks

Um eine Aufgabe zu erstellen, die nicht vollständig in einem Thread ausgeführt werden sollen, wird der TaskCompletionSource<TResult>-Typ verwendet. Dieser Typ macht eine Task-Eigenschaft verfügbar, die eine zugeordnete Task<TResult>-Instanz zurückgibt. Der Lebenszyklus dieser Aufgabe wird durch TaskCompletionSource<TResult>-Methoden wie SetResult, SetException, SetCanceled und ihre TrySet Varianten gesteuert.

Nehmen wir beispielsweise an, dass Sie eine Aufgabe erstellen möchten, die nach einem bestimmten Zeitraum abgeschlossen wird. Beispielsweise könnten Sie eine Aktivität in der Benutzeroberfläche verzögern. Die System.Threading.Timer-Klasse ermöglicht es bereits, einen Delegaten nach einem angegebenen Zeitraum asynchron aufzurufen, und mit TaskCompletionSource<TResult> lässt sich eine Task<TResult>-Front für den Timer erstellen, wie im folgenden Beispiel:

public static Task<DateTimeOffset> Delay(int millisecondsTimeout)
{
    TaskCompletionSource<DateTimeOffset> tcs = null;
    Timer timer = null;

    timer = new Timer(delegate
    {
        timer.Dispose();
        tcs.TrySetResult(DateTimeOffset.UtcNow);
    }, null, Timeout.Infinite, Timeout.Infinite);

    tcs = new TaskCompletionSource<DateTimeOffset>(timer);
    timer.Change(millisecondsTimeout, Timeout.Infinite);
    return tcs.Task;
}
Public Function Delay(millisecondsTimeout As Integer) As Task(Of DateTimeOffset)
    Dim tcs As TaskCompletionSource(Of DateTimeOffset) = Nothing
    Dim timer As Timer = Nothing

    timer = New Timer(Sub(obj)
                          timer.Dispose()
                          tcs.TrySetResult(DateTimeOffset.UtcNow)
                      End Sub, Nothing, Timeout.Infinite, Timeout.Infinite)

    tcs = New TaskCompletionSource(Of DateTimeOffset)(timer)
    timer.Change(millisecondsTimeout, Timeout.Infinite)
    Return tcs.Task
End Function

Die Task.Delay-Methode wird zu diesem Zweck bereitgestellt. Sie können sie in einer anderen asynchronen Methode verwenden, beispielsweise zum Implementieren einer asynchrone Abrufschleife:

public static async Task Poll(Uri url, CancellationToken cancellationToken,
                              IProgress<bool> progress)
{
    while(true)
    {
        await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken);
        bool success = false;
        try
        {
            await DownloadStringAsync(url);
            success = true;
        }
        catch { /* ignore errors */ }
        progress.Report(success);
    }
}
Public Async Function Poll(url As Uri, cancellationToken As CancellationToken,
                           progress As IProgress(Of Boolean)) As Task
    Do While True
        Await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken)
        Dim success As Boolean = False
        Try
            await DownloadStringAsync(url)
            success = true
        Catch
            ' ignore errors
        End Try
        progress.Report(success)
    Loop
End Function

Die TaskCompletionSource<TResult>-Klasse hat keine nicht generische Entsprechung. Task<TResult> wird jedoch von Task abgeleitet, sodass das generische TaskCompletionSource<TResult>-Objekt für Ein-/Ausgabe-gebundene Methoden verwendet werden kann, die einfach eine Aufgabe zurückgeben. Um dies zu erreichen, können Sie eine Quelle mit einem Dummy-TResult (Boolean) verwenden. Das ist eine gute Standardmöglichkeit, aber wenn Sie darüber besorgt sind, dass der Benutzer der Task sie in eine Task<TResult> umwandeln wird, können Sie stattdessen einen privaten TResult-Typ verwenden). Beispielsweise wurde die zuvor gezeigte Delay-Methode entwickelt, um die aktuelle Zeit zusammen mit dem resultierenden Offset (Task<DateTimeOffset>) zurückzugeben. Wenn ein solcher Ergebniswert nicht erforderlich ist, kann die Methode stattdessen wie folgt codiert werden (beachten Sie die Änderung des Rückgabetyps und die Änderung des Arguments in TrySetResult):

public static Task<bool> Delay(int millisecondsTimeout)
{
     TaskCompletionSource<bool> tcs = null;
     Timer timer = null;

     timer = new Timer(delegate
     {
         timer.Dispose();
         tcs.TrySetResult(true);
     }, null, Timeout.Infinite, Timeout.Infinite);

     tcs = new TaskCompletionSource<bool>(timer);
     timer.Change(millisecondsTimeout, Timeout.Infinite);
     return tcs.Task;
}
Public Function Delay(millisecondsTimeout As Integer) As Task(Of Boolean)
    Dim tcs As TaskCompletionSource(Of Boolean) = Nothing
    Dim timer As Timer = Nothing

    Timer = new Timer(Sub(obj)
                          timer.Dispose()
                          tcs.TrySetResult(True)
                      End Sub, Nothing, Timeout.Infinite, Timeout.Infinite)

    tcs = New TaskCompletionSource(Of Boolean)(timer)
    timer.Change(millisecondsTimeout, Timeout.Infinite)
    Return tcs.Task
End Function

Tasks, die sowohl rechnergebunden als auch E/A-gebunden sind

Asynchrone Methoden sind nicht auf ausschließlich rechnergebundene oder ausschließlich E/A-gebundene Vorgänge beschränkt, sondern können eine Mischung aus beiden Vorgangsarten darstellen. Tatsächlich werden mehrere asynchrone Operationen häufig zu größeren Mischvorgängen kombiniert. Die bereits gezeigte RenderAsync-Methode hat beispielsweise einen rechenintensiven Vorgang ausgeführt, um ein Bild auf Grundlage von eingegebenen imageData zu rendern. Die imageData können aus einem Webdienst stammen, auf den asynchron zugegriffen wird:

public async Task<Bitmap> DownloadDataAndRenderImageAsync(
    CancellationToken cancellationToken)
{
    var imageData = await DownloadImageDataAsync(cancellationToken);
    return await RenderAsync(imageData, cancellationToken);
}
Public Async Function DownloadDataAndRenderImageAsync(
             cancellationToken As CancellationToken) As Task(Of Bitmap)
    Dim imageData As ImageData = Await DownloadImageDataAsync(cancellationToken)
    Return Await RenderAsync(imageData, cancellationToken)
End Function

Dieses Beispiel zeigt auch, wie ein einzelnes Abbruchstoken nacheinander in mehreren asynchronen Vorgängen verwendet werden kann. Weitere Informationen finden Sie im Abschnitt zur Verwendung von Abbruchtokens unter Verwenden des aufgabenbasierten asynchronen Musters.

Siehe auch