Implementar el modelo asincrónico basado en tareas

Puede implementar el patrón asincrónico basado en tareas (TAP) de tres maneras: mediante los compiladores de C# y Visual Basic en Visual Studio, manualmente o mediante una combinación del compilador y métodos manuales. En las siguientes secciones se describe cada método con detalle. Puede usar el modelo TAP para implementar operaciones asincrónicas enlazadas a cálculos y enlazadas a E/S. En la sección Cargas de trabajo se trata cada tipo de operación.

Generación de métodos TAP

Uso de compiladores

A partir de .NET Framework 4.5, cualquier método que tenga la palabra clave async (Async en Visual Basic) se considera un método asincrónico, y los compiladores de C# y Visual Basic realizan las transformaciones necesarias para implementar el método de forma asincrónica mediante TAP. Un método asincrónico debe devolver un objeto System.Threading.Tasks.Task o System.Threading.Tasks.Task<TResult>. En el último caso, el cuerpo de la función debe devolver TResult y el compilador garantiza que este resultado está disponible a través del objeto de la tarea resultante. Del mismo modo, las excepciones sin controlar en el cuerpo del método se serializan en la tarea de salida y hacen que la tarea resultante finalice en el estado TaskStatus.Faulted. La excepción a esta regla es cuando un objeto OperationCanceledException (o un tipo derivado) no está controlado, en cuyo caso la tarea resultante finaliza en el estado TaskStatus.Canceled.

Generar métodos de TAP manualmente

Puede implementar el patrón TAP manualmente para tener un mejor control sobre la implementación. El compilador se basa en el área de superficie pública expuesta del espacio de nombres System.Threading.Tasks y los tipos auxiliares del espacio de nombres System.Runtime.CompilerServices. Para implementar TAP, cree un objeto TaskCompletionSource<TResult>, realice la operación asincrónica y, cuando se complete, llame al método SetResult, SetException o SetCanceled, o a la versión Try de uno de estos métodos. Cuando implementa un método de TAP manualmente, debe completar la tarea resultante cuando la operación asincrónica representada se complete. Por ejemplo:

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

Enfoque híbrido

Puede resultar útil implementar el patrón TAP manualmente pero delegar la lógica básica de la implementación en el compilador. Por ejemplo, es posible que quiera usar el enfoque híbrido para comprobar argumentos fuera de un método asincrónico generado por el compilador de forma que las excepciones puedan salir del autor de llamada directo del método en lugar de exponerse a través del 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

Otro caso donde es útil esa delegación es cuando implementa la optimización de acceso rápido y desea devolver una tarea almacenada en memoria caché.

Cargas de trabajo

Puede implementar operaciones asincrónicas enlazadas a cálculos y enlazadas a E/S como métodos de TAP. Sin embargo, cuando los métodos de TAP se exponen públicamente desde una biblioteca, solo se deben suministrar para cargas de trabajo que impliquen operaciones enlazadas a E/S (también pueden implicar cálculos, pero no deben ser estrictamente de cálculo). Si un método está totalmente enlazado a cálculos, se debe exponer solo como una implementación sincrónica. El código que lo usa puede elegir si ajustar una invocación de ese método sincrónico en una tarea para descargar el trabajo en otro subproceso o para lograr el paralelismo. Si un método está enlazado a E/S, se debe exponer solo como una implementación asincrónica.

Tareas enlazadas a cálculos

La clase System.Threading.Tasks.Task es idónea para representar operaciones de cálculo intensivas. De forma predeterminada, se beneficia de la compatibilidad especial dentro de la clase ThreadPool para proporcionar una ejecución eficaz, y también proporciona un buen control sobre cuándo, dónde y cómo se ejecutan los cálculos asincrónicos.

Puede generar tareas enlazadas a cálculos de las maneras siguientes:

  • En .NET Framework 4.5 y versiones posteriores (incluidos .NET Core, y .NET 5 y versiones posteriores), use el método Task.Run estático como un acceso directo a TaskFactory.StartNew. Puede usar Run para iniciar fácilmente una tarea enlazada a cálculos destinada al grupo de subprocesos. Este es el mecanismo preferido para iniciar una tarea enlazada al cálculo. Use StartNew directamente solo cuando desee un mayor control sobre la tarea.

  • En .NET Framework 4, use el método TaskFactory.StartNew, que acepta un delegado (normalmente Action<T> o Func<TResult>) para que se ejecute de forma asincrónica. Si proporciona un delegado de Action<T>, el método devuelve un objeto System.Threading.Tasks.Task que representa la ejecución asincrónica de ese delegado. Si proporciona un delegado de Func<TResult>, el método devuelve un objeto System.Threading.Tasks.Task<TResult>. Las sobrecargas del método StartNew aceptan un token de cancelación (CancellationToken), las opciones de creación de la tarea (TaskCreationOptions) y un programador de tareas (TaskScheduler), todo lo cual proporciona un control específico sobre la programación y la ejecución de la tarea. Una instancia de generador que tiene como destino el programador de tareas actual está disponible como una propiedad estática (Factory) de la clase Task; por ejemplo: Task.Factory.StartNew(…).

  • Use los constructores del tipo Task y el método Start si quiere generar y programar la tarea por separado. Los métodos públicos solo deben devolver tareas que ya se han iniciado.

  • Use las sobrecargas del método Task.ContinueWith. Este método crea una nueva tarea que se programa cuando se completa otra tarea. Algunas de las sobrecargas de ContinueWith aceptan un token de cancelación, opciones de continuación y un programador de tareas para tener un mejor control sobre la programación y la ejecución de la tarea de continuación.

  • Use los métodos TaskFactory.ContinueWhenAll y TaskFactory.ContinueWhenAny. Estos métodos crean una nueva tarea que se programa cuando se completa una parte o la totalidad del conjunto de tareas proporcionado. Estos métodos también proporcionan sobrecargas para controlar la programación y la ejecución de estas tareas.

En las tareas enlazadas a cálculos, el sistema puede evitar la ejecución de una tarea programada si recibe una solicitud de cancelación antes de que comience la ejecución de la tarea. Por tanto, si proporciona un token de cancelación (objeto CancellationToken), puede pasar ese token al código asincrónico que lo supervisa. También puede proporcionar el token a uno de los métodos mencionados previamente, como StartNew o Run, para que el runtime de Task también supervise el token.

Por ejemplo, considere un método asincrónico que presenta una imagen. El cuerpo de la tarea puede sondear el token de cancelación para que el código pueda salir pronto si llega una solicitud de cancelación durante la representación. Además, si la solicitud de cancelación llega antes de que se inicie la representación, deseará evitar la operación de representación:

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

Las tareas enlazadas a cálculos finalizan en un estado Canceled si se cumple al menos una de las condiciones siguientes:

  • Llega una solicitud de cancelación a través del objeto CancellationToken, que se proporciona como argumento al método de creación (por ejemplo, StartNew o Run) antes de que la tarea cambie al estado Running.

  • Una excepción OperationCanceledException no está controlada dentro del cuerpo de esta tarea, esa excepción contiene el mismo objeto CancellationToken que se pasa a la tarea y ese token muestra que se solicitó la cancelación.

Si hay otra excepción no controlada en el cuerpo de la tarea, la tarea finaliza en el estado Faulted y cualquier intento de esperar en la tarea u obtener acceso a su resultado produce una excepción.

Tareas enlazadas a E/S

Para crear una tarea a la que no deba respaldar directamente un subproceso durante toda su ejecución, use el tipo TaskCompletionSource<TResult>. Este tipo expone una propiedad Task que devuelve una instancia asociada de Task<TResult>. El ciclo de vida de esta tarea se controla mediante métodos TaskCompletionSource<TResult> como SetResult, SetException, SetCanceled y sus variantes de TrySet.

Suponga que desea crear una tarea que se completará después de un período de tiempo especificado. Por ejemplo, puede que desee retrasar una actividad en la interfaz de usuario. La clase System.Threading.Timer ya proporciona la capacidad de invocar de forma asincrónica un delegado después de un período de tiempo especificado, y TaskCompletionSource<TResult> le permite colocar un objeto Task<TResult> delante del temporizador, por ejemplo:

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

El método Task.Delay se proporciona con este propósito y puede usarlo dentro de otro método asincrónico, por ejemplo, para implementar un bucle asincrónico de sondeo:

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

La clase TaskCompletionSource<TResult> no tiene ningún homólogo no genérico. Sin embargo, Task<TResult> deriva de Task, por lo que puede usar el objeto genérico TaskCompletionSource<TResult> para los métodos enlazados a E/S que simplemente devuelven una tarea. Para ello, puede usar un origen con un TResult ficticio (Boolean es una buena opción predeterminada, pero si le preocupa que el usuario de Task lo convierta en tipos inferiores a un objeto Task<TResult>, puede usar un tipo TResult privado en su lugar). Por ejemplo, el método Delay del ejemplo anterior devuelve la hora actual junto con el desplazamiento resultante (Task<DateTimeOffset>). Si este valor de resultado es innecesario, el método podría codificarse como sigue (observe el cambio del tipo de valor devuelto y el cambio del argumento a 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

Tareas enlazadas a cálculos y enlazadas a E/S mixtas

Los métodos asincrónicos no se limitan solo a operaciones enlazadas a cálculos o enlazadas a E/S, sino que pueden representar una combinación de ambas. De hecho, se suelen combinar varias operaciones asincrónicas en operaciones mixtas mayores. Por ejemplo, el método RenderAsync de un ejemplo anterior realizaba una operación de cálculo intensiva para presentar una imagen basada en imageData de entrada. Este imageData podría proceder de un servicio Web al que tiene acceso de forma asincrónica:

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

En este ejemplo también se muestra cómo se puede incluir en un subproceso un único token de cancelación mediante varias operaciones asincrónicas. Para más información, vea la sección de uso de la cancelación en Utilizar el modelo asincrónico basado en tareas.

Vea también