Uno dei motivi ricorrenti per cui si creano dei thread è quello di voler eseguire un’operazione in modo asincrono rispetto al flusso di esecuzione corrente.
Abbiamo già visto come sia possibile, usando i thread pool, richiedere l’esecuzione di una funzione in un thread separato senza doversi preoccupare di crearlo e distruggerlo. Con i thread pool, però, una parte del problema non viene completamente risolta: il passaggio dei parametri, infatti, può avvenire solo trasferendo le informazioni necessarie in oggetti creati ad-hoc.
Il paradigma della programmazione asincrona è realizzato in .NET con un meccanismo che rende quasi trasparente anche quest’aspetto. Vediamo di cosa si tratta.
Partiamo da una semplice chiamata a funzione:
using System; using System.Threading; class Demo { static void WorkBehind() { Console.WriteLine( "Work behind, thread ID = {0}", AppDomain.GetCurrentThreadId() ); } static void Main(string[] args) { WorkBehind(); Console.WriteLine( "Main, thread ID = {0}", AppDomain.GetCurrentThreadId() ); } }
Il risultato prodotto è il seguente:
Work behind, thread ID = 2660 Main, thread ID = 2660
Ovviamente la chiamata è sincrona. Vediamo cosa succede se dichiariamo un delegate e invochiamo la stessa funzione attraverso il delegate:
using System; using System.Threading; public delegate void WorkBehindCallback(); class Demo { static void WorkBehind() { Console.WriteLine( "Work behind, thread ID = {0}", AppDomain.GetCurrentThreadId() ); } static void Main(string[] args) { WorkBehindCallback callback = new WorkBehindCallback( WorkBehind ); callback(); Console.WriteLine( "Main, thread ID = {0}", AppDomain.GetCurrentThreadId() ); } }
Apparentemente non è cambiato nulla; infatti il risultato è identico al precedente, a parte il valore di thread ID che può cambiare ad ogni esecuzione:
Work behind, thread ID = 2684 Main, thread ID = 2684
Se però cambiamo il modo in cui viene invocata la funzione associata al delegate, lo scenario è completamente diverso:
using System; using System.Threading; public delegate void WorkBehindCallback(); class Demo { static void WorkBehind() { Console.WriteLine( "Work behind, thread ID = {0}", AppDomain.GetCurrentThreadId() ); } static void Main(string[] args) { WorkBehindCallback callback = new WorkBehindCallback( WorkBehind ); callback.BeginInvoke( null, null ); Console.WriteLine( "Main, thread ID = {0}", AppDomain.GetCurrentThreadId() ); // Wait completion Thread.Sleep(1000); } }
Questa volta l’output generato dimostra che abbiamo ottenuto un’esecuzione asincrona rispetto alla funzione chiamante.
Main, thread ID = 2328 Work behind, thread ID = 2416
La scritta prodotta nel Main questa volta precede la scritta prodotta nella funzione WorkBehind, e in particolare i due ID dei thread sono diversi. In realtà l’ordine potrebbe anche essere l’opposto:
Work behind, thread ID = 2416 Main, thread ID = 2328
È bene ricordare che il delegate è in realtà una classe molto particolare del runtime. Come ogni classe, può avere dei membri di istanza: in questo caso abbiamo usato BeginInvoke, che richiede un’esecuzione asincrona della funzione associata al delegate. Tralasciamo per il momento i parametri di BeginInvoke.
In effetti la funzione WorkBehind viene eseguita tramite la classe ThreadPool, quindi valgono tutte le considerazioni fatte per i Thread Pool, come ad esempio la necessità di attendere un po’ di tempo (in questo caso un secondo) prima dell’uscita da Main per evitare che i worker thread vengano eliminati prima che abbiano finito di eseguire la funzione WorkBehind.
Un approccio meno approssimativo al problema di attendere il completamento di WorkBehind consiste nell’utilizzare il membro AsyncWaitHandle dell’interfaccia IAsyncResult restituita da BeginInvoke. Quest’oggetto di sincronizzazione generico dispone del metodo WaitOne per attendere la segnalazione dell’evento che rappresenta.
using System; using System.Threading; public delegate void WorkBehindCallback(); class Demo { static void WorkBehind() { Console.WriteLine( "Work behind, thread ID = {0}", AppDomain.GetCurrentThreadId() ); } static void Main(string[] args) { WorkBehindCallback callback = new WorkBehindCallback( WorkBehind ); IAsyncResult ar = callback.BeginInvoke( null, null ); Console.WriteLine( "Main, thread ID = {0}", AppDomain.GetCurrentThreadId() ); // Wait completion ar.AsyncWaitHandle.WaitOne(); } }
L’interfaccia IAsyncResult restituita da BeginInvoke contiene le informazioni necessarie a identificare la chiamata asincrona effettuata. Un uso che abbiamo appena visto è quello di sincronizzarsi col completamento della funzione chiamata. Un altro uso è quello relativo al passaggio dei parametri: un aspetto interessante della chiamata asincrona tramite il delegate è che possiamo passare dei parametri alla funzione chiamata, così come possiamo ricevere un eventuale valore di ritorno, nonché gli eventuali parametri passati per riferimento. L’uso di EndInvoke sul delegate consente di realizzare sia la sincronizzazione che il passaggio dei parametri di uscita.
using System; using System.Threading; public delegate int WorkBehindCallback( int a, ref int b, out int c ); class Demo { static int WorkBehind( int a, ref int b, out int c ) { Console.WriteLine( "Work behind, thread ID = {0}", AppDomain.GetCurrentThreadId() ); b++; c = a * 2; return (a + b); } static void Main(string[] args) { int b, c, result; b = 9; WorkBehindCallback callback = new WorkBehindCallback( WorkBehind ); IAsyncResult ar = callback.BeginInvoke( 32, ref b, out c, null, null ); Console.WriteLine( "Main, thread ID = {0}", AppDomain.GetCurrentThreadId() ); result = callback.EndInvoke( ref b, out c, ar ); Console.WriteLine( "result = {0}, b = {1}, c = {2}", result, b, c ); } }
La funzione callback ha ora dei parametri di ingresso e di uscita. BeginInvoke riceve tutti i parametri di ingresso, mentre EndInvoke restituisce lo stesso valore della funzione callback, oltre ad avere come parametri anche quelli passati per riferimento. Va notato che tutti i parametri passati per riferimento sono presenti sia in BeginInvoke che in EndInvoke, sebbene i parametri contrassegnati con out non siano effettivamente utilizzati da BeginInvoke (out non è CLS-compliant ed è trattato come se fosse ref).
Lo schema seguente semplifica il meccanismo con cui sono generate le funzioni BeginInvoke e EndInvoke in funzione della signature del metodo con cui viene definito un delegate:
delegate bool Func( int a, ref int b, out int c ); IAsyncResult BeginInvoke( int a, ref int b, out int c, AsyncCallback cb, object AsyncState ); bool EndInvoke( ref int b, out int c, IAsyncResult ar );
Il metodo BeginInvoke può essere chiamato solo se il delegate è associato a una sola funzione.
Dal punto di vista della sincronizzazione, EndInvoke attende la fine dell’esecuzione della chiamata asincrona (in questo caso di WorkBehind) prima di continuare l’esecuzione, esattamente come avveniva chiamando ar.AsyncWaitHandle.WaitOne nell’esempio precedente.
In tutti i casi visti, è il thread chiamante che ha la responsabilità di ricevere le informazioni di uscita della chiamata asincrona. Spesso si rivela più comodo delegare tale compito ad una funzione specifica, anch’essa richiamabile in modo asincrono: in questo modo non è più necessario definire nel codice dei punti di controllo in cui attendere la fine della chiamata asincrona per avviare le operazioni successive.
La tecnica appena descritta è implementata nell’esempio che segue.
using System; using System.Threading; public delegate int WorkBehindCallback( int a, ref int b, out int c ); class Demo { static int WorkBehind( int a, ref int b, out int c ) { Console.WriteLine( "Work behind, thread ID = {0}", AppDomain.GetCurrentThreadId() ); b++; c = a * 2; return (a + b); } static void Completion( IAsyncResult ar ) { int b, c, result; b = 0; WorkBehindCallback callback = (WorkBehindCallback)ar.AsyncState; result = callback.EndInvoke( ref b, out c, ar ); Console.WriteLine( "result = {0}, b = {1}, c = {2}", result, b, c ); } static void Main(string[] args) { int b, c; b = 9; WorkBehindCallback callback = new WorkBehindCallback( WorkBehind ); IAsyncResult ar = callback.BeginInvoke( 32, ref b, out c, new AsyncCallback( Completion ), callback ); Console.WriteLine( "Main, thread ID = {0}", AppDomain.GetCurrentThreadId() ); // Wait completion Thread.Sleep( 1000 ); } }
Partiamo dalla chiamata a BeginInvoke: gli ultimi due parametri sono rispettivamente la callback da chiamare al completamento di WorkBehind e un parametro che viene trasferito così com’è a tale callback. In questo caso passiamo l’istanza del delegate che punta a WorkBehind, in modo che dall’interno di Completion possiamo chiamare la relativa EndInvoke. Ovviamente questa modalità di operare è molto didattica, perché se il caso reale fosse veramente così semplice, sarebbe stato più efficace un approccio basato sull’uso di un metodo di istanza anziché statico come callback di completamento. Possiamo vedere tale approccio nell’esempio successivo.
using System; using System.Threading; public delegate int WorkBehindCallback( int a, ref int b, out int c ); class AsyncDemo { private int b, c, result; private WorkBehindCallback callback; void Completion( IAsyncResult ar ) { result = callback.EndInvoke( ref b, out c, ar ); Console.WriteLine( "result = {0}, b = {1}, c = {2}", result, b, c ); } public AsyncDemo( WorkBehindCallback callback ) { b = 9; this.callback = callback; } public void Run() { callback.BeginInvoke( 32, ref b, out c, new AsyncCallback( Completion ), null ); } } class Demo { static int WorkBehind( int a, ref int b, out int c ) { Console.WriteLine( "Work behind, thread ID = {0}", AppDomain.GetCurrentThreadId() ); b++; c = a * 2; return (a + b); } static void Main(string[] args) { AsyncDemo demo = new AsyncDemo( new WorkBehindCallback( WorkBehind ) ); demo.Run(); Console.WriteLine( "Main, thread ID = {0}", AppDomain.GetCurrentThreadId() ); // Wait completion Thread.Sleep( 1000 ); } }
Anche se il codice è più lungo, è certamente più modulare e più sicuro, e di conseguenza più semplice da interpretare. Il più delle volte, poi, quest’approccio consente di ridurre i parametri di ingresso e uscita dalla funzione di callback: in questo caso WorkBehind è una funzione statica all’interno della classe Demo, ma se avessimo dichiarato WorkBehind come membro di istanza di una classe, i dati della classe potevano essere utilizzati per trasportare le informazioni necessarie alla callback asincrona.
Gli scenari sono molti e la scelta di quale utilizzare va ponderata in funzione del contesto in cui si utilizza la chiamata asincrona. In generale, se la funzione è molto generica, probabilmente i parametri saranno passati nella chiamata stessa, se invece la funzione è molto specializzata può avere più senso pensare ad una funzione membro di una classe che contiene tutte le informazioni necessarie. La valutazione migliore va comunque ponderata caso per caso.
