Implementazione del modello asincrono basato su attività

È possibile implementare il modello asincrono basato su attività (TAP) in i tre modi: con i compilatori C# e Visual Basic in Visual Studio, manualmente oppure con una combinazione dei primi due. Le sezioni seguenti illustrano in dettaglio ogni metodo. È possibile usare il modello TAP per implementare operazioni asincrone di calcolo e di I/O. La sezione Carichi di lavoro illustra ogni tipo di operazione.

Generazione di metodi TAP

Uso dei compilatori

A partire da .NET Framework 4.5, qualsiasi metodo con la parola chiave async (Async in Visual Basic) viene considerato un metodo asincrono e i compilatori C# e Visual Basic eseguono le trasformazioni necessarie per implementare il metodo in modo asincrono tramite TAP. Un metodo asincrono deve restituire un oggetto System.Threading.Tasks.Task o System.Threading.Tasks.Task<TResult>. Nel secondo caso, il corpo della funzione deve restituire un oggetto TResult e il compilatore garantisce che il risultato sia reso disponibile tramite l'oggetto attività risultante. Allo stesso modo, viene effettuato il marshalling di qualsiasi eccezione gestita all'interno del corpo del metodo per l'attività di output, per far sì che l'attività risultante termini con lo stato TaskStatus.Faulted. L'eccezione a questa regola si verifica quando un OperationCanceledException (o tipo derivato) non viene gestito, in qual caso l'attività risultante termina nello stato TaskStatus.Canceled.

Generazione manuale di metodi TAP

È possibile implementare il modello TAP manualmente per un controllo migliore sull'implementazione. Il compilatore si basa sull'area di superficie esposta dallo spazio dei nomi System.Threading.Tasks e i tipi di supporto nello spazio dei nomi System.Runtime.CompilerServices. Per implementare autonomamente il modello TAP, creare un oggetto TaskCompletionSource<TResult>, eseguire l'operazione asincrona e, al completamento, chiamare il metodo SetResult, SetException o SetCanceled oppure la versione Try di uno di questi metodi. Quando si implementa un metodo TAP manualmente, è necessario completare l'attività risultante al completamento dell'operazione asincrona rappresentata. Ad esempio:

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

Approccio ibrido

Potrebbe essere utile implementare manualmente il modello TAP, ma delegare la logica di base per l'implementazione al compilatore. È ad esempio possibile usare l'approccio ibrido per verificare gli argomenti all'esterno di un metodo asincrono generato dal compilatore, in modo che le eccezioni possano aggirare il metodo chiamante diretto anziché essere esposte tramite l'oggetto 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

Un altro caso in cui tale delega è utile si verifica quando si implementa l'ottimizzazione fast-path e si vuole restituire un'attività nella cache.

Carichi di lavoro

È possibile implementare le operazioni asincrone di solo calcolo e associate ai I/O come metodi TAP. Tuttavia, quando i metodi TAP vengono esposti pubblicamente da una libreria, dovrebbero essere forniti solo per i carichi di lavoro che implicano operazioni associate a I/O (che possono anche implicare il calcolo, ma non devono essere puramente di calcolo). Se un metodo è di solo calcolo, deve essere esposto solo come implementazione sincrona. Il codice che lo utilizza può quindi scegliere se eseguire il wrapping di una chiamata di tale metodo sincrono in un'attività per l'offload del lavoro in un altro thread oppure per ottenere parallelismo. Se invece un metodo è di I/O, deve essere esposto solo come implementazione asincrona.

Attività di calcolo

La classe System.Threading.Tasks.Task è la soluzione ideale per la rappresentazione di operazioni con calcoli complessi. Per impostazione predefinita, consente di usufruire del supporto speciale all'interno della classe ThreadPool per fornire un'esecuzione efficiente e fornisce anche un controllo significativo su quando, dove e come eseguire i calcoli asincroni.

È possibile generare attività di calcolo nei seguenti modi:

  • In .NET Framework 4.5 e versioni successive inclusi .NET Core e .NET 5+, usare il metodo statico Task.Run come collegamento a TaskFactory.StartNew. È possibile usare Run per avviare facilmente un'attività di calcolo destinata al pool di thread. Questo è il meccanismo preferenziale per l'avvio di un'attività di calcolo. Usare StartNew direttamente solo quando si vuole controllare l'attività in modo più accurato.

  • In .NET Framework 4 usare il metodo TaskFactory.StartNew, che accetta l'esecuzione asincrona di un delegato (di solito Action<T> o Func<TResult>). Se l'utente fornisce un delegato Action<T>, il metodo restituisce un oggetto System.Threading.Tasks.Task che rappresenta l'esecuzione asincrona di tale delegato. Se si fornisce un delegato Func<TResult>, il metodo restituisce un oggetto System.Threading.Tasks.Task<TResult>. Gli overload del metodo StartNew accettano un token di annullamento (CancellationToken), opzioni di creazione attività (TaskCreationOptions) e un'utilità di pianificazione delle attività (TaskScheduler), tutti i quali offrono un controllo granulare sulla pianificazione e l'esecuzione dell'attività. Un'istanza della factory destinata all'utilità di pianificazione dell'attività corrente è disponibile come proprietà statica (Factory) della classe Task; ad esempio: Task.Factory.StartNew(…).

  • Usare i costruttori del tipo Task e del metodo Start se si vuol generare e pianificare l'attività separatamente. I metodi pubblici devono restituire solo operazioni già avviate.

  • Usare gli overload del metodo Task.ContinueWith. Questo metodo crea una nuova attività pianificata al completamento di un'altra attività. Alcuni degli overload ContinueWith accettano un token di annullamento, opzioni di continuazione e un'utilità di pianificazione delle attività per un miglior un controllo sulla pianificazione e l'esecuzione dell'attività di continuazione.

  • Usare i metodi TaskFactory.ContinueWhenAll e TaskFactory.ContinueWhenAny. Questi metodi creano una nuova attività pianificata al completamento di tutte o una qualsiasi delle attività di un set fornito. Questi metodi forniscono anche gli overload per controllare la pianificazione e l'esecuzione di queste attività.

Nelle operazioni di calcolo, il sistema può impedire l'esecuzione di un'operazione pianificata se riceve una richiesta di annullamento prima dell'avvio dell'esecuzione dell'attività. In tal caso, se si fornisce un token di annullamento (oggetto CancellationToken), è possibile passare tale token al codice asincrono che monitora il token. È anche possibile fornire il token a uno dei metodi indicati in precedenza, ad esempio StartNew o Run in modo che il runtime Task possa monitorare anche il token.

Si consideri ad esempio un metodo asincrono che esegue il rendering di un'immagine. Il corpo dell'attività può eseguire il polling del token di annullamento in modo che il codice esca anticipatamente se arriva una richiesta di annullamento durante il rendering. Inoltre, se arriva la richiesta di annullamento prima dell'inizio del rendering, sarà opportuno evitare l'operazione di rendering:

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

Le attività di calcolo terminano in uno stato Canceled se almeno una delle condizioni seguenti si verifica:

  • Una richiesta di annullamento arriva tramite l'oggetto CancellationToken, fornito come argomento al metodo di creazione, (ad esempio StartNew o Run) prima che l'attività passi allo stato Running.

  • Un'eccezione OperationCanceledException non viene gestita nel corpo di tale attività, tale eccezione contiene lo stesso oggetto CancellationToken passato all'attività e il token indica che viene richiesto l'annullamento.

Se un'altra eccezione non viene gestita nel corpo dell'attività, l'attività termina nello stato Faulted e tutti i tentativi di attesa dell'attività o di accesso al risultato causano la generazione di un'eccezione.

Attività di I/O

Per creare un'attività che non deve essere supportata direttamente da un thread per l'intera esecuzione, usare il tipo TaskCompletionSource<TResult>. Questo tipo espone una proprietà Task che restituisce un'istanza di Task<TResult> associata. Il ciclo di vita di questa attività viene controllato con i metodi TaskCompletionSource<TResult> come SetResult, SetException, SetCanceled e relative varianti TrySet.

Si supponga di voler creare un'attività che verrà completata dopo un periodo di tempo specificato. Ad esempio, è possibile ritardare un'attività nell'interfaccia utente. La classe System.Threading.Timer consente già di richiamare in modo asincrono un delegato dopo un determinato periodo di tempo e usando TaskCompletionSource<TResult> è possibile inserire un oggetto Task<TResult> all'inizio del timer, ad esempio:

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

Il metodo Task.Delay viene fornito a questo scopo e può essere usato in un altro metodo asincrono, ad esempio, per implementare un ciclo asincrono di polling:

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 classe TaskCompletionSource<TResult> non ha una controparte non generica. Tuttavia, Task<TResult> deriva da Task, quindi è possibile usare l'oggetto generico TaskCompletionSource<TResult> per i metodi associati a I/O che restituiscono semplicemente un'attività. A questo scopo, è possibile usare un database di origine con oggetto TResult fittizio (Boolean è una scelta ottimale predefinita, ma se si teme che l'utente di Task ne esegua il downcast in Task<TResult>, è possibile usare un tipo privato TResult). Ad esempio, il metodo Delay nell'esempio precedente restituisce l'ora corrente con l'offset risultante (Task<DateTimeOffset>). Se tale valore non è necessario, il metodo potrebbe invece essere codificato come segue (notare la modifica del tipo restituito e la modifica dell'argomento 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

Attività miste di calcolo e di I/O

I metodi asincroni non sono limitati solo a operazioni associate a calcolo o I/O, ma possono rappresentare una combinazione di entrambe. Infatti, più operazioni asincrone vengono combinate spesso in operazioni miste di dimensioni maggiori. In un esempio precedente, tramite il metodo RenderAsync era stata effettuata un'operazione complessa a livello di calcolo per eseguire il rendering di un'immagine basata su un input imageData. Questi imageData possono provenire da un servizio Web a cui si accede in modo asincrono:

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

In questo esempio viene illustrato come un unico token di annullamento può essere multithreading con più operazioni asincrone. Per altre informazioni, vedere la sezione relativa all'annullamento in Utilizzo del modello asincrono basato su attività.

Vedi anche