Task Based Asyncronous Pattern (TAP) – Parte I

T

Il task-based asyncronous pattern, meglio noto come TAP, è il pattern di programmazione asincrona raccomandato dalla Microsoft per lo sviluppo di applicativi in .NET Framework; tuttavia prima di avventurarsi nella lettura di questa serie di articoli, è necessario avere una conoscenza basilare sulla programmazione asincrona: perciò se non vi sentite troppo a vostro agio con questo paradigma di programmazione, vi consiglio prima di leggere questo articolo.

Il .NET Framework fornisce tre pattern per la programmazione asincrona:

  • Asynchronous Programming Model (APM) (noto anche come IAsyncResult). In questo pattern le operazioni asincrone richiedono i metodi Begin e End. Questo pattern è sconsigliato per nuovo codice.
  • Event-based Asynchronous Pattern (EAP). Questo pattern richiede metodi con il suffisso Async; inoltre richiede uno o più eventi, handler di eventi delegati e tipi che estendono EventArg. Questo pattern è sconsigliato per nuovo codice.
  • Task-based Asynchronous Pattern (TAP). Questo pattern si basa sull’idea di rappresentare le operazioni asincrone con un metodo e quindi combinare lo stato dell’operazione e l’API che viene utilizzata per interagire con queste operazioni in un singolo oggetto. Tale oggetto è Task o Task<TResult>, nel namespace System.Threading.Tasks. Per fornire il supporto a questi oggetti, le parole chiave async ed await sono state introdotte nel linguaggio per fornire il supporto a TAP.

    TAP è stato introdotto nel .NET Framework 4 ed è l’approccio raccomandato per la programmazione asincrona

    TAP in origine è stato creato per risolvere i seguenti problemi:

  • Permettere operazioni che richiedono molto tempo per giungere al completamento, senza che il thread della UI venga bloccato. Creare un nuovo thread per operazioni che non svolgono lavoro intensivo sulla CPU è piuttosto oneroso, considerato che tutto ciò che il thread farebbe è attendere che l’attività richiesta sia completata.
  • Quando l’attività viene completata (sia attraverso un nuovo thread che tramite una callback), è spesso necessario effettuare la sincronizzazione con il chiamante che ha inizializzato l’attività.
  • Fornire un nuovo pattern che funziona sia con operazioni asincrone che fanno uso intensivo della CPU che con quelle che non ne usano molta.

Naming, Parametri e Tipi Ritornati

TAP richiede un solo metodo per rappresentare sia l’inizio che il completamento di un’operazione asincrona. Questo metodo ritorna un oggetto di tipo Task o Task<TResult>, a seconda che il metodo sincrono corrispondente ritorni dati o meno. I parametri di un metodo TAP dovrebbero coincidere con quelli della controparte sincrona, ordine compreso; eccezion fatta per i parametri out e ref, che andrebbero evitati del tutto. I dati che andrebbero ritornati tramite parametri out o ref andrebbero piuttosto inseriti nel TResult tramite una Tuple o una qualsiasi altra struttura dati personalizzata che possa contenere tali valori.
In questa serie di articoli verranno presentati degli esempi di codice dalla dubbia utilità pratica (altrimenti che esempi sarebbero?) ma sicuramente utili per fissare alcuni concetti chiave. In particolare, costruiremo un downloader HTTP TAP attraverso WebClient.DownloadData o, dove necessario, il suo corrispettivo asincrono.

Creazione dei metodi TAP

Tutto molto bello fino ad ora. Ma dopo avervi riempito di una vagonata di informazioni storiche e stilistiche, immagino vi stiate chiedendo: ok, ma quando iniziamo?
Eccovi accontentati! Iniziamo col dire che i metodi TAP possono essere implementati in tre modi: usando il compilatore C# di Visual Studio, manualmente, o tramite un approccio ibrido.
Nelle sezioni successive vi parlerò, più o meno approfonditamente, di ognuno dei metodi citati.

Creazione dei metodi TAP usando il compilatore

Questo approccio è senza dubbio il più banale, anche se ovviamente è quello che offre minore libertà d’implementazione; infatti il compilatore C# eseguirà tutte le trasformazioni necessarie per implementare un metodo asincrono usando TAP ogni qual volta incontri un metodo che presenta la parola chiave async.

Creazione dei metodi TAP manualmente

Per avere un controllo maggiore sull’implementazione, è possibile implementare i metodi TAP manualmente. Per farlo, dovrete creare un oggetto TaskCompletionSource, eseguire l’operazione asincrona e, quando sarà completata, chiamare uno tra i metodi SetResult, SetException o SetCanceled; o la loro versione TrySet.

public static Task<byte[]> Download(string address)
{
    var tcs = new TaskCompletionSource<byte[]>();
    Task.Factory.StartNew(() =>
    {
        var client = new WebClient();
        try
        {
            byte[] downloaded = client.DownloadData(address);
            tcs.SetResult(downloaded);
        }
        catch (Exception e)
        {
            tcs.SetException(e);
        }
    });
    return tcs.Task;
}

Creazione dei metodi TAP con approccio ibrido

A volte può essere utile implementare i metodi TAP manualmente, ma delegare la logica interna al compilatore. Ad esempio, potreste voler usare un approccio ibrido nel caso in cui vogliate verificare i parametri passati ad un metodo prima che questo avvii la chiamata asincrona. In questo modo un’eventuale eccezione verrebbe lanciata direttamente invece che esposta tramite l’oggetto Task.
Un esempio più che valido è l’utilizzo di
WebClient.DownloadDataTaskAsync, ovvero il metodo TAP fornito dalla Microft stessa, inserito in un nostro wrapper.

public static async Task<byte[]> Download(string address)
{
    if (address == null) throw new ArgumentNullException("address");
    var client = new WebClient();
    return await client.DownloadDataTaskAsync(address);
}

Carico di lavoro

Molto bene, ora sapete come creare un metodo TAP. Dopo averci giocato per un po’ tuttavia, vi sarà (spero) sorta la domanda: quando caspiterina uso TAP e quando no? È così luccicoso, lo voglio usare sempre!
Ebbene, è certamente possibile implementare metodi TAP sia per attività intensive sulla CPU che per attività che eseguono molte operazioni di I/O, quindi quello scintillio è tutto vostro! Ma attenti ad avvicinarvi troppo alla luce o fate la fine delle Icaro; infatti se state scrivendo una libreria pubblica che espone metodi TAP, questi dovrebbero coinvolgere prevalentemente attività di I/O (ovviamente l’uso di CPU non è precluso, ma deve essere minoritario).
Contrariamente, un metodo che è puramente computazionale, dovrebbe fornire solo un’implementazione sincrona.

Eccezioni

Come visto in precedenza, un’eccezione viene propagata da un metodo TAP al chiamante attraverso SetException. Le classiche eccezioni invece, che interromperebbero bruscamente il flow asincrono, sono possibili solo per errori d’utilizzo, i quali non dovrebbero mai avvenire con codice in produzione. Ad esempio, come visto nell’approccio ibrido, un controllo su una null reference viene effettuato prima dell’effettiva chiamata al metodo asincrono.

Un vecchio saggio una volta disse: non lanciate mai eccezioni, ma catturatele sempre.

Stato dei task

Ma allora, se lanciare eccezioni è sconsigliabile, come facciamo a gestire l’esito di un’operazione asincrona? In qualche modo bisogna capire se ha fatto o se è morta malamente ad un certo punto?
Fortunatamente la classe Task espone il ciclo di vita di un’operazione asincrona. Tale ciclo è rappresentato dall’enumerabile TaskStatus. Siccome qualcuno là fuori potrebbe alzarsi la mattina e decidere di creare la sua classe che estende Task o Task<TResult>; o ancora, potrebbe decidere di separare la costruzione dell’oggetto dall’avvio dell’operazione. Per supportare questi casi limite, la classe Task espone un metodo Start.
Tutti i task che vengono creati utilizzando il costruttore pubblico e quindi avviati tramite il metodo Start, si dice che partano “a freddo”, in quanto iniziano il loro ciclo di vita nello stato Created e non vengono schedulati fintanto che non viene invocato tale metodo. Tutti gli altri task, ovvero quelli che hanno l’operazione asincrona associata ad uno stato iniziale diverso da Created, si dice che partano “a caldo”. In entrambi i casi, un metodo TAP deve ritornare un task attivo, quindi se un metodo usa il costruttore pubblico, dovrà chiamare il metodo Start prima di ritornare il task; in questo modo gli utenti di un metodo TAP possono assumere che il task sia attivo e quindi non doversi porre la domanda se invocare Start o meno, in quanto tale invocazione su un task già attivo porterebbe all’eccezione InvalidOperationException.

Sospendere l’esecuzione con Await

In C#, è possibile utilizzare la parola chiave await per attendere in modo asincrono un oggetto Task o Task<TResult>. Se siete in attesa di un Task, l’await è di tipo void;, mentre se siete in attesa di un Task<TResult>, l’await è di tipo TResult. Va notato che l’await deve essere sempre dentro al corpo del metodo asincrono.
In realtà l’await non fa altro che inserire una callback il cui scopo è quello di riprendere l’esecuzione del metodo asincrono nel suo punto di sospensione. Tale callback, se l’operazione in attesa è completata con successo, ritorna void o TResult a seconda che fosse Task o Task<TResult>. Contrariamente, se l’operazione in await termina in uno stato Canceled, lancia un’eccezione OperationCanceledException. Similmente, se tale operazione termina nello stato Faulted, l’eccezione interna viene propagata.
Quando un metodo asincrono viene chiamato, il suo corpo è eseguito in modo sincrono fino alla prima espressione in attesa. A quel punto l’esecuzione ritorna al chiamante.

Annullamento

Fino ad ora abbiamo capito come creare un metodo TAP e come aspettare che questo finisca. Ma…oh cacchio! Mentre giocherellavo con in nuovi giocattoli ho messo a scaricare l’internet. Abort mission! I repeat, abort mission!
Fortunatamente TAP mette a disposizione un metodo semplice ed indolore per annullare un’operazione in corso. Chiaramente l’implementazione del metodo asincrono non è tenuta a fornire il supporto all’annullamento: in alcuni casi semplicemente non avrebbe senso; se però un metodo supporta la cancellazione, espone un overload che accetta un token di annullamento (istanza della classe CancellationToken). Per convenzione, tale parametro è denominato cancellationToken.
L’operazione asincrona terrà quindi sotto controllo questo token per intercettare richieste di annullamento. Se una richiesta perviene, può decidere se onorare la richiesta e annullare l’operazione o meno. Nel caso in cui la richiesta di annullamento comporti lavoro troncato prematuramente, il metodo TAP conclude con stato Canceled: non ci sono risultati disponibili e non viene lanciata alcuna eccezione. Canceled è uno stato finale per un task. Come già detto, il codice che è in await di un task che termina nello stato Canceled, riceverà un eccezione del tipo OperationCanceledException, o una sua classe figlia. Il codice che invece è bloccato in modo sincrono, in attesa tramite Wait o WaitAll, continuerà la sua esecuzione con un’eccezione.

Token di Annullamento

Un token di annullamento è creato attraverso l’oggetto CancellationTokenSource. La proprietà Token di questo oggetto ritorna il token necessario per annullare l’operazione asincrona corrente. Torniamo ad esempio all’esempio precedente in cui avevamo creato un downloader con approccio ibrido; supponiamo di aver inserito l’indirizzo sbagliato (abbiamo messo a scaricare tutto l’internet, ricordate?) e quindi di voler annullare l’operazione. Creare un oggetto CancellationTokenSource, passarlo al metodo TAP ed infine invocare altrove il metodo Cancel, è tutto ciò che serve.

var cts = new CancellationTokenSource();
var client = new WebClient();
byte[] data = await client.DownloadDataTaskAsync(address, cts.Token);
// più tardi, potenzialmente in un altro thread
cts.Cancel();

Se al contrario un metodo richiede il CancellationToken tra i suoi parametri, ma non avete intenzione di annullare l’operazione, potete passare il valore CancellationToken.None; in questo modo state implicitamente avvisando il metodo, e di conseguenza il compilatore, che l’annullamento non avverrà mai. In questo caso, la proprietà CancellationToken.CanBeCanceled ritornerà falso, e il metodo chiamato può essere ottimizzato conseguentemente
L’utilizzo di un token ha alcuni vantaggi:

  • Lo stesso token può essere passato ad un numero arbitrario di metodi.
  • Una sola richiesta di annullamento può essere intercettata da un numero arbitrario di listener.
  • Lo sviluppatore dell’API asincrona ha pieno controllo sulla possibilità di annullare un’operazione ed eventualmente quanto una richiesta dovrebbe avere effetto.

Avanzamento Operazione

Scaricare l’internet non è esattamente una cosa fattibile, però scaricare un file parecchio grande sì! Ad esempio, OpenStreetMap permette di scaricare la mappa del mondo intero: nel momento della scrittura di questo articolo la versione XML si attesta intorno ai 98 GiB mentre la versione in PBF intorno ai 64 GiB.
Diciamo di voler scaricare la versione in PBF, perché ehi: uno non può attuare il suo malvagio piano di conquista del mondo se prima non sa come è fatto!
C’è solo un piccolo inghippo, ringraziando i fantastici ISP italiani, viaggio a 7Mbps nei giorni buoni: come faccio a sapere quanto ho scaricato nelle ultime 3 ore?
Ancora una volta, TAP ci viene incontro. Nei metodi TAP è infatti possibile gestire agevolmente lo stato di avanzamento di un’operazione tramite l’interfaccia IProgress<T>, che viene passata come parametro al metodo stesso ed è generalmente chiamata progress. Se un metodo TAP consente un overload che accetta un parametro di avanzamento, deve permettere e gestire il caso in cui tale parametro sia nullo, ovvero quando la funzionalità non è richiesta. Le implementazioni TAP dovrebbero segnalare l’avanzamento all’oggetto Progress<T> in modo sincrono e permettere al consumatore di tali metodi di decidere come e quando è più opportuno gestire l’informazione.
Nell’esempio sottostante vediamo due porzioni di codice: la prima mostra come usare la classe Progress, mentre la seconda riguarda l’implementazione del metodo TAP associato, che associa al parametro progress un EventHandler il quale a sua volta aggiorna l’interfaccia IProgress.

Download("https://planet.openstreetmap.org/pbf/full-history/history-latest.osm.pbf", 
                new Progress<int>(p => Console.WriteLine(p)));
private static async Task<Byte[]> Download(string address, IProgress<int> progress)
{
    var client = new WebClient();
    if (progress != null) {
        client.DownloadProgressChanged += (sender, e) => 
        {
           progress.Report(e.ProgressPercentage); 
        };
    }
    return await client.DownloadDataTaskAsync(address);
}

Sitografia

Task-based Asyncronous Pattern, Documentazione Microsoft

A proposito di me

Nico Caprioli

Si diletta con i computer sin dall'età di 8 anni, per sbarcare nel mondo della programmazione durante il liceo.
Dopo una laurea magistrale in Ingegneria Informatica, passa le sue giornate a battere i tasti sperando di scrivere codice C# valido.

I nostri Partner

Gli articoli più letti

Articoli recenti

Commenti recenti