Implementowanie wzorca asynchronicznego opartego na zadaniach

Wzorzec asynchroniczny oparty na zadaniach (TAP) można zaimplementować na trzy sposoby: przy użyciu kompilatorów języka C# i Visual Basic w Visual Studio, ręcznie lub za pomocą kombinacji metod kompilatora i metod ręcznych. W poniższych sekcjach szczegółowo omówiono każdą metodę. Możesz użyć wzorca TAP, aby zaimplementować zarówno operacje asynchroniczne powiązane z obliczeniami, jak i operacje asynchroniczne związane z operacjami we/wy. W sekcji Obciążenia omówiono każdy typ operacji.

Generowanie metod TAP

Korzystanie z kompilatorów

Począwszy od .NET Framework 4.5, każda metoda oznaczona słowem kluczowym async (Async w Visual Basic) jest uważana za metodę asynchroniczną. Kompilatory języka C# i Visual Basic wykonują niezbędne przekształcenia, aby wdrożyć metodę asynchronicznie przy użyciu interfejsu TAP. Metoda asynchroniczna powinna zwracać System.Threading.Tasks.Task obiekt lub System.Threading.Tasks.Task<TResult> . W przypadku tego ostatniego treść funkcji powinna zwrócić wartość TResult, a kompilator gwarantuje, że ten wynik zostanie udostępniony za pośrednictwem wynikowego obiektu zadania. Podobnie, wszystkie wyjątki, które nie są obsługiwane wewnątrz metody, są przekazywane do zadania wynikowego i powodują zakończenie tego zadania w stanie TaskStatus.Faulted. Wyjątkiem od tej reguły jest sytuacja, gdy OperationCanceledException (lub typ pochodny) nie jest obsłużony, w takim przypadku wynikowe zadanie kończy się w stanie TaskStatus.Canceled.

Zadanie.Uruchamianie i usuwanie zadań

Użyj Start tylko dla zadań jawnie utworzonych z konstruktora Task, które znajdują się nadal w stanie Created. Publiczne metody TAP powinny zwracać aktywne zadania, więc wywołujący nie powinni wywoływać metody Start.

W większości kodu TAP nie usuwaj zadań. Element Task nie przechowuje zasobów niezarządzanych w typowym przypadku, a usuwanie każdego zadania wprowadza dodatkowe obciążenie bez praktycznych korzyści. Usuń tylko wtedy, gdy określone interfejsy API lub pomiary wskazują na konieczność.

Jeśli rozpoczniesz pracę w tle, która będzie trwała dłużej niż bezpośredni proces wywołania, utrzymuj jasność co do własności i monitoruj ukończenie. Aby uzyskać więcej wskazówek, zobacz Utrzymywanie aktywności metod asynchronicznych.

Ręczne generowanie metod TAP

Możesz ręcznie zaimplementować wzorzec tap w celu uzyskania lepszej kontroli nad implementacją. Kompilator korzysta z publicznej powierzchni eksponowanej przez przestrzeń nazw System.Threading.Tasks oraz typów pomocniczych w przestrzeni nazw System.Runtime.CompilerServices. Aby zaimplementować interfejs TAP samodzielnie, należy utworzyć obiekt, wykonać operację asynchroniczną, a po jej zakończeniu wywołać TaskCompletionSource<TResult>metodę SetResult , SetExceptionlub SetCanceledTry wersję jednej z tych metod. W przypadku ręcznego implementowania metody TAP należy wykonać wynikowe zadanie po zakończeniu reprezentowanej operacji asynchronicznej. Przykład:

static class StreamExtensions
{
    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;
    }
}
Module StreamExtensions
    <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
End Module

Podejście hybrydowe

Przydatne może być ręczne zaimplementowanie wzorca TAP, ale delegowanie podstawowej logiki implementacji do kompilatora. Na przykład możesz chcieć użyć podejścia hybrydowego, gdy chcesz zweryfikować argumenty poza metodą asynchroniczną wygenerowaną przez kompilator, aby wyjątki mogły być przekazywane bezpośredniemu wywołującemu metodę, zamiast być ujawniane za pośrednictwem obiektu System.Threading.Tasks.Task.

class Calculator
{
    private int value = 0;

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

    private async Task<int> MethodAsyncInternal(string input)
    {
        // code that uses await goes here
        await Task.Delay(1);
        return value;
    }
}
Class Calculator
    Private value As Integer = 0

    Public Function MethodAsync(input As String) As Task(Of Integer)
        If input Is Nothing Then Throw New ArgumentNullException(NameOf(input))
        Return MethodAsyncInternal(input)
    End Function

    Private Async Function MethodAsyncInternal(input As String) As Task(Of Integer)
        ' code that uses await goes here
        Await Task.Delay(1)
        Return value
    End Function
End Class

Innym przypadkiem, w którym takie delegowanie jest przydatne, jest zaimplementowanie optymalizacji szybkiej ścieżki i zwrócenie buforowanego zadania.

Obciążenia pracą

Operacje asynchroniczne powiązane z obliczeniami i we/wy można zaimplementować jako metody TAP. Jednak w przypadku uwidocznienia metod TAP publicznie z biblioteki, powinny one być dostępne tylko dla obciążeń związanych z operacjami zależnymi od we/wy. Te operacje mogą również obejmować obliczenia, ale nie powinny być czysto obliczeniowe. Jeśli metoda jest wyłącznie powiązana z obliczeniami, uwidacznia ją tylko jako implementację synchroniczną. Kod, który go używa, może następnie zdecydować, czy opakować wywołanie tej metody synchronicznej w zadanie, aby przenieść pracę na inny wątek lub osiągnąć równoległość. Jeśli metoda jest związana z operacjami I/O, udostępniaj ją tylko jako implementację asynchroniczną.

Zadania związane z obliczeniami

Klasa System.Threading.Tasks.Task działa dobrze do reprezentowania operacji intensywnie korzystających z obliczeń. Domyślnie korzysta ze specjalnego wsparcia w klasie ThreadPool w celu zapewnienia wydajnego wykonywania. Zapewnia również znaczącą kontrolę nad tym, kiedy, gdzie i jak są wykonywane obliczenia asynchroniczne.

Wygeneruj zadania związane z obliczeniami na następujące sposoby:

  • W programach .NET Framework 4.5 lub nowszych (w tym .NET Core i .NET 5+) użyj metody statycznej Task.Run jako skrótu do TaskFactory.StartNew. Służy Run do łatwego uruchamiania zadania powiązanego z obliczeniami, które jest przeznaczone dla puli wątków. Ta metoda jest preferowanym mechanizmem uruchamiania zadania powiązanego z obliczeniami. Używaj StartNew bezpośrednio tylko wtedy, gdy chcesz uzyskać bardziej szczegółową kontrolę nad zadaniem.

  • W programie .NET Framework 4 użyj metody TaskFactory.StartNew. Akceptuje delegata (zazwyczaj Action<T> lub Func<TResult>), aby wykonać go asynchronicznie. Jeśli podasz Action<T> delegata, metoda zwraca System.Threading.Tasks.Task obiekt reprezentujący asynchroniczne wykonanie tego delegata. Jeśli podasz delegata Func<TResult> , metoda zwróci System.Threading.Tasks.Task<TResult> obiekt. Przeciążenia StartNew metody akceptują token anulowania (CancellationToken), opcje tworzenia zadań (TaskCreationOptions) i harmonogram zadań (TaskScheduler). Te parametry zapewniają szczegółową kontrolę nad planowaniem i wykonywaniem zadania. Wystąpienie fabryki, które jest przeznaczone dla bieżącego Factory harmonogramu zadań, jest dostępne jako właściwość statyczna (Task) klasy. Na przykład: Task.Factory.StartNew(…).

  • Użyj konstruktorów Task typu i Start metody , jeśli chcesz wygenerować i zaplanować zadanie oddzielnie. Metody publiczne muszą zwracać tylko zadania, które zostały już uruchomione.

  • Użyj przeciążeń metody Task.ContinueWith. Ta metoda tworzy nowe zadanie zaplanowane po zakończeniu innego zadania. ContinueWith Niektóre przeciążenia akceptują token anulowania, opcje kontynuacji i harmonogram zadań, aby lepiej kontrolować planowanie i wykonywanie zadania kontynuacji.

  • Użyj metody TaskFactory.ContinueWhenAll i TaskFactory.ContinueWhenAny. Te metody tworzą nowe zadanie, które jest zaplanowane po zakończeniu wszystkich lub dowolnego zestawu dostarczonych zadań. Te metody oferują również przeciążenia pozwalające kontrolować harmonogramowanie i wykonywanie tych zadań.

W zadaniach związanych z obliczeniami system może uniemożliwić wykonanie zaplanowanego zadania, jeśli otrzyma żądanie anulowania przed rozpoczęciem uruchamiania zadania. W związku z tym, jeśli podasz token anulowania jako obiekt (CancellationToken), możesz przekazać ten token do kodu asynchronicznego, który go monitoruje. Możesz również podać token do jednej z wcześniej wymienionych metod, takich jak StartNew lub Run, tak aby Task środowisko uruchomieniowe mogło również monitorować token.

Rozważmy na przykład metodę asynchroniczną, która renderuje obraz. Treść zadania może monitorować token anulowania, aby kod zakończył działanie wcześniej, jeżeli pojawi się żądanie anulowania podczas renderowania. Ponadto jeśli żądanie anulowania zostanie dostarczone przed rozpoczęciem renderowania, chcesz zapobiec operacji renderowania:

internal static 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

Note

W tym przykładzie użyto Bitmap, który wymaga pakietu System.Drawing.Common i jest obsługiwany tylko w Windows. Wzorzec zadania powiązanego z obliczeniami — przy użyciu Task.Run z CancellationToken — ma zastosowanie na wszystkich platformach; zastąp wieloplatformową bibliotekę obrazów dla obiektów docelowych innych niż Windows.

Zadania związane z obliczeniami kończą się stanem Canceled , jeśli spełniony jest co najmniej jeden z następujących warunków:

  • Żądanie anulowania dociera za pośrednictwem obiektu CancellationToken, który jest przekazywany jako argument do metody tworzenia (np. StartNew lub Run) przed przejściem zadania w stan Running.

  • Wyjątek OperationCanceledException jest nieobsługiwany w treści takiego zadania. Ten wyjątek zawiera to samo CancellationToken, które jest przekazywane do zadania, a ten token wskazuje, że żądanie anulowania zostało złożone.

Jeśli inny wyjątek nie zostanie obsłużony w ciele zadania, zadanie kończy się w stanie Faulted. Wszelkie próby oczekiwania na zadanie lub uzyskanie dostępu do jego wyniku powodują zgłoszenie wyjątku.

Zadania ograniczone przez we/wy

Aby utworzyć zadanie, które nie powinno bezpośrednio używać wątku do wykonywania całego procesu, użyj pewnego typu TaskCompletionSource<TResult>. Ten typ udostępnia właściwość Task, która zwraca skojarzone wystąpienie Task<TResult>. Cykl życia tego zadania można kontrolować przy użyciu TaskCompletionSource<TResult> metod, takich jak SetResult, SetException, SetCanceledi ich TrySet warianty.

Załóżmy, że chcesz utworzyć zadanie, które zostanie ukończone po określonym przedziale czasu. Na przykład możesz opóźnić działanie w interfejsie użytkownika. Klasa System.Threading.Timer zapewnia już możliwość asynchronicznego wywoływania delegata po określonym przedziale czasu. Za pomocą TaskCompletionSource<TResult> można dodać Task<TResult> na licznik czasu. Przykład:

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

Dla tego celu udostępniono metodę Task.Delay. Można go użyć wewnątrz innej metody asynchronicznej, na przykład w celu zaimplementowania asynchronicznej pętli sondowania:

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

Klasa TaskCompletionSource<TResult> nie ma niegenerycznego odpowiednika. Jednak Task<TResult> pochodzi z Task, więc można użyć ogólnego obiektu TaskCompletionSource<TResult> dla metod powiązanych z we/wy, które po prostu zwracają zadanie. Aby to zrobić, użyj źródła z atrapą TResult (Boolean to dobry wybór domyślny, ale jeśli obawiasz się, że użytkownik Task zrzuci go do Task<TResult>, możesz użyć zamiast tego prywatnego typu TResult). Na przykład Delay metoda w poprzednim przykładzie zwraca bieżący czas wraz z wynikowym przesunięciem (Task<DateTimeOffset>). Jeśli taka wartość wyniku jest niepotrzebna, można zamiast tego kodować metodę w następujący sposób (zwróć uwagę na zmianę typu zwracanego i zmianę argumentu na TrySetResult):

public static Task<bool> DelaySimple(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 DelaySimple(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

Mieszane zadania związane z obliczeniami i związane z we/wy

Metody asynchroniczne nie są ograniczone tylko do operacji związanych z obliczeniami lub operacji wejścia/wyjścia. Mogą reprezentować mieszaninę tych dwóch. W rzeczywistości często łączy się wiele operacji asynchronicznych w większe operacje mieszane. Na przykład RenderAsync metoda w poprzednim przykładzie wykonuje operację intensywnie korzystającą z obliczeń w celu renderowania obrazu na podstawie niektórych danych wejściowych imageData. Może to imageData pochodzić z usługi internetowej, do której asynchronicznie uzyskujesz dostęp:

public static 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

Note

W tym przykładzie użyto Bitmap, który wymaga pakietu System.Drawing.Common i jest obsługiwany tylko w Windows. Wzorzec tworzenia łańcucha pobierania asynchronicznego za pomocą asynchronicznych operacji związanych z obliczeniami ma zastosowanie na wszystkich platformach; zastąp wieloplatformową bibliotekę obrazów dla obiektów docelowych innych niż Windows.

W tym przykładzie pokazano również, jak pojedynczy token anulowania może być przekazywany przez wiele operacji asynchronicznych. Aby uzyskać więcej informacji, zobacz sekcję o użyciu anulowania w Korzystanie ze wzorca asynchronicznego opartego na zadaniach.

Zobacz także