Implementando o padrão assíncrono baseado em tarefa

Você pode implementar o Padrão Assíncrono baseado em Tarefas (TAP) de três formas: usando os compiladores C# e Visual Basic no Visual Studio, manualmente ou por meio de uma combinação dos métodos de compilador e manual. As seções a seguir discutem cada método em detalhes. Você pode usar o padrão TAP para implementar operações assíncronas associadas ao cálculo e associadas à E/S. A seção Cargas de trabalho descreve cada tipo de operação.

Gerando métodos do TAP

Usando os compiladores

Do .NET Framework 4.5 em diante, qualquer método que seja atribuído com a palavra-chave async (Async no Visual Basic) é considerado um método assíncrono e os compiladores de C# e do Visual Basic realizam as transformações necessárias para implementar o método de forma assíncrona usando o TAP. Um método assíncrono deve retornar um objeto System.Threading.Tasks.Task ou um objeto System.Threading.Tasks.Task<TResult>. Para o último, o corpo da função deve retornar um TResult, e o compilador garante que esse resultado seja disponibilizado por meio do objeto de tarefa resultante. Da mesma forma, quaisquer exceções que passem para o corpo do método sem tratamento são empacotadas na tarefa de saída e fazem com que a tarefa resultante termine no estado TaskStatus.Faulted. A exceção a esta regra é quando uma OperationCanceledException (ou um tipo derivado) passa sem tratamento e, nesse caso, a tarefa resultante termina no estado TaskStatus.Canceled.

Gerando métodos do TAP manualmente

Você pode implementar o padrão TAP manualmente para obter maior controle sobre a implementação. O compilador depende da área de superfície pública exposta no namespace System.Threading.Tasks e dos tipos de suporte no namespace System.Runtime.CompilerServices. Para implementar o TAP você mesmo, crie um objeto TaskCompletionSource<TResult>, execute a operação assíncrona e quando ela estiver concluída, chame o método SetResult, SetException ou SetCanceled, ou a versão Try de um desses métodos. Quando você implementa um método do TAP manualmente, deverá concluir a tarefa resultante quando a operação assíncrona representada for concluída. Por exemplo:

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

Abordagem híbrida

Você pode achá-la útil para implementar o padrão TAP manualmente, mas também para delegar a lógica principal para a implementação para o compilador. Por exemplo, talvez você queira usar a abordagem híbrida quando quiser verificar argumentos fora de um método assíncrono gerado pelo compilador para que as exceções possam escapar para o chamador direto do método, em vez de serem expostas por meio do objeto System.Threading.Tasks.Task:

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

Outro caso em que tal delegação é útil é quando você está implementando a otimização de caminho rápido e deseja retornar uma tarefa armazenada em cache.

Cargas de trabalho

Você pode implementar operações assíncronas associadas ao cálculo e associadas à E/S como métodos do TAP. No entanto, quando os métodos do TAP forem expostos publicamente de uma biblioteca, eles deverão receber somente cargas de trabalho que envolvem operações associadas à E/S (eles também podem envolver cálculos, mas não devem ser puramente computacionais). Se um método for puramente vinculado à computação, ele deverá ser exposto apenas como uma implementação síncrona. O código que o consome pode então escolher se deseja encapsular uma invocação desse método síncrono em uma tarefa para descarregar o trabalho em outro thread ou para obter paralelismo. Além disso, se um método for associado à E/S, ele deverá ser exposto apenas como uma implementação assíncrona.

Tarefas associadas ao cálculo

A classe System.Threading.Tasks.Task é totalmente adequada para representar operações que exigem vários recursos computacionais. Por padrão, ela aproveita o suporte especial dentro da classe ThreadPool para fornecer execução eficiente e fornece também controle significativo sobre quando, onde e como executar cálculos assíncronos.

Você pode gerar tarefas associadas ao cálculo de uma das seguintes maneiras:

  • No .NET Framework 4.5 e em versões posteriores (incluindo o .NET Core e o .NET 5+), use o método Task.Run estático como um atalho para o TaskFactory.StartNew. Você pode usar Run para iniciar com facilidade uma tarefa associada ao cálculo que tem como destino o pool de threads. Esse é o mecanismo preferido para iniciar uma tarefa associada ao cálculo. Use StartNew diretamente somente quando desejar um controle mais refinado sobre a tarefa.

  • No .NET Framework 4, use o método TaskFactory.StartNew, que aceita um delegado (geralmente um Action<T> ou um Func<TResult>) a ser executado de forma assíncrona. Se você fornecer um delegado Action<T>, o método retornará um objeto System.Threading.Tasks.Task que representa a execução assíncrona desse delegado. Se você fornecer um delegado Func<TResult>, o método retornará um objeto System.Threading.Tasks.Task<TResult>. Sobrecargas do método StartNew aceitam um token de cancelamento (CancellationToken), opções de criação de tarefas (TaskCreationOptions) e um agendador de tarefas (TaskScheduler), que fornecem controle refinado sobre o planejamento e a execução da tarefa. Uma instância de fábrica que tem como destino o agendador de tarefas atual está disponível como uma propriedade estática (Factory) da classe Task; por exemplo: Task.Factory.StartNew(…).

  • Use os construtores do tipo Task ou o método Start para gerar e agendar a tarefa separadamente. Métodos públicos devem retornar somente as tarefas que já foram iniciadas.

  • Use as sobrecargas do método Task.ContinueWith. Esse método cria uma nova tarefa que é agendada quando outra tarefa for concluída. Algumas das sobrecargas ContinueWith aceitam um token de cancelamento, opções de continuação e um agendador de tarefas para obter melhor controle sobre o agendamento e a execução da tarefa de continuação.

  • Use os métodos TaskFactory.ContinueWhenAll e TaskFactory.ContinueWhenAny. Esses métodos criam uma nova tarefa que é agendada quando todos ou nenhum dos conjuntos de tarefas for concluído. Esses métodos também fornecem sobrecargas para controlar o agendamento e a execução dessas tarefas.

Nas tarefas associadas ao cálculo, o sistema pode impedir a execução de uma tarefa agendada se ele receber uma solicitação de cancelamento antes de iniciar a execução da tarefa. Assim, se você fornecer um token de cancelamento (objeto CancellationToken), poderá passar esse token para o código assíncrono que monitora o token. Também é possível fornecer o token para um dos métodos mencionados anteriormente, tais como StartNew ou Run para que o runtime da Task também possa monitorar o token.

Por exemplo, considere um método assíncrono que renderiza uma imagem. O corpo da tarefa pode sondar o token de cancelamento para que o código possa sair antecipadamente se uma solicitação de cancelamento chegar durante a renderização. Além disso, se a solicitação de cancelamento chegar antes do início da renderização, você desejará impedir a operação de renderização:

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

Tarefas associadas ao cálculo terminarão em um estado Canceled se pelo menos uma das seguintes condições for verdadeira:

  • Uma solicitação de cancelamento chega por meio do objeto CancellationToken, que é fornecido como um argumento para o método de criação (por exemplo, StartNew ou Run) antes das transições de tarefas para o estado Running.

  • Uma exceção OperationCanceledException ficará sem tratamento dentro do corpo dessa tarefa, se a exceção contiver o mesmo CancellationToken que é passado para a tarefa e se o token mostrar que o cancelamento foi solicitado.

Se outra exceção ficar sem tratamento dentro do corpo da tarefa, a tarefa terminará no estado Faulted e quaisquer tentativas de aguardar a tarefa ou acessar seu resultado fará com que uma exceção seja lançada.

Tarefas associadas à E/S

Para criar uma tarefa cujo backup não deve ser feito diretamente por um thread para a totalidade de sua execução, use o tipo TaskCompletionSource<TResult>. Esse tipo expõe uma propriedade Task que retorna uma instância Task<TResult> associada. O ciclo de vida dessa tarefa é controlado por métodos TaskCompletionSource<TResult>, tais como SetResult, SetException, SetCanceled e suas variantes TrySet.

Digamos que você deseja criar uma tarefa que será concluída após um período de tempo especificado. Por exemplo, talvez você queira atrasar uma atividade na interface do usuário. A classe System.Threading.Timer já oferece a capacidade de invocar de forma assíncrona um delegado após um período especificado e usando TaskCompletionSource<TResult> você pode colocar uma frente Task<TResult> no cronômetro, por exemplo:

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

O método Task.Delay é fornecido para essa finalidade e você pode usá-lo dentro de outro método assíncrono, por exemplo, para implementar um loop de sondagem assíncrono:

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

A classe TaskCompletionSource<TResult> não tem uma equivalente não genérica. No entanto, Task<TResult> deriva de Task, de modo que você pode usar o objeto TaskCompletionSource<TResult> genérico para métodos de associação de E/S que simplesmente retornam uma tarefa. Para fazer isso, é possível usar uma fonte com um TResult fictício (Boolean é uma boa opção padrão, mas se você estiver preocupado sobre o usuário que fará downcast da Task para uma Task<TResult>, poderá, em vez disso, usar um tipo TResult particular). Por exemplo, o método Delay no exemplo anterior retorna a hora atual com o deslocamento resultante (Task<DateTimeOffset>). Se esse valor de resultado for desnecessário, o método poderia então ser codificado da seguinte maneira (observe a alteração de tipo de retorno e a alteração de argumento para 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

Tarefas mistas associadas ao cálculo e associadas à E/S

Os métodos assíncronos não estão limitados apenas a operações associadas ao cálculo ou associadas à E/S, mas podem representar uma mistura das duas. Na verdade, várias operações assíncronas são geralmente combinadas em operações mistas maiores. Por exemplo, o método RenderAsync em um exemplo anterior executou uma operação que exige muitos recursos computacionais para renderizar uma imagem com base em algumas entradas imageData. Essa imageData poderia ser proveniente de um serviço da web que você acessa de forma assíncrona:

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

Esse exemplo também demonstra como um token de cancelamento único pode ser encadeado por meio de várias operações assíncronas. Para saber mais, veja a seção de uso de cancelamento em Consumindo o padrão assíncrono baseado em tarefa.

Confira também