Panoramica e concetti
C# supporta l'esecuzione parallela di codice tramite il multithreading. Un thread è un flusso indipendente di esecuzione, capace di "girare" contemporanemente insieme ad altri thread.
Un programma C# inizia in un singolo thread creato automaticamente dal CLR e dal sistema operativo (il thread principale), e diviene multi-thread nel momento in cui vengono creati altri thread. Un semplice esempio e il suo ouput:
Tutti gli esempi necessitano l'uso dei seguenti namespace, salvo diversamente specificato:
using System;
using System.Threading;
class ThreadTest {
static void Main() {
Thread t = new Thread (WriteY);
t.Start(); // Scrivi WriteY nel nuovo thread
while (true) Console.Write ("x"); // Scrivi 'x' indefinitivamente
}
static void WriteY() {
while (true) Console.Write ("y"); // Scrivi 'y' indefinitivamente
}
}
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...
Il thread principale crea un nuovo thread t nel quale viene eseguito un metodo che ripetutamente stampa il carattere y. Contemporanemente, il thread principale stampa ripetutamente il carattere x.
Il CLR assegna a ogni thread il proprio stack di memoria, al fine di garantire l'isolamento delle variabili locali.Nel prossimo esempio, definiamo un metodo con una variabile locale, dopo chiameremo il metodo contemporanemente sul thread principale e sul nuovo thread appena creato:
static void Main() {
new Thread (Go).Start(); // Chiama Go() sul nuovo thread
Go(); // Chiama Go() sul thread principale
}
static void Go() {
// Dichiara e usa la variabile - 'cycles'
for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}
??????????
Una copia separata della variabile cycles viene creata nello stack di memoria di ogni thread, e , come sperato, l'output sono dieci punti interrogativi.
I Thread condividono dei dati se hanno in comune un riferimento alla stessa istanza di un oggetto. Per esempio:
class ThreadTest {
bool done;
static void Main() {
ThreadTest tt = new ThreadTest(); // Crea un'istanza comune
new Thread (tt.Go).Start();
tt.Go();
}
// Notare che Go() è ora un metodo d'istanza
void Go() {
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
Poichè entrambi i thread invocano Go() sulla stessa istanza di ThreadTest , condividono il campo done. Pertanto "Done" verrà stampato una volta invece di due:
Done
I campi static sono un altro modo per condividere dati fra i thread. Ecco lo stesso esempio in cui done viene dichiarato come campo statico:
class ThreadTest {
static bool done; // I campi statici sono condivisi fra tutti i thread
static void Main() {
new Thread (Go).Start();
Go();
}
static void Go() {
if (!done) { done = true; Console.WriteLine ("Done"); }
}
}
Entrambi gli esempi illustrano un altro concetto chiave -quello del thread safety (o, piuttosto, la mancanza!) L'output rimane attualmente indeterminato: è possibile (sebbene improbabile) che "Done" venga stampato due volte. Se, comunque, invertiamo l'ordine delle istruzioni nel metodo Go, allora il primo dei "Done" being printed twice go up dramatically:
static void Go() {
if (!done) { Console.WriteLine ("Done"); done = true; }
}
Done
Done (di solito!)
Il problema è che un thread valuta l'istruzione if proprio mentre l'altro thread sta eseguendo l'istruzione WriteLine – prima di poter impostare done a true.
Il rimedio è ottenere un lock esclusivo sia in lettura che in scrittura per il campo condiviso. C# fornisce l'istruzione lock proprio per questo scopo:
class ThreadSafe {
static bool done;
static object locker = new object();
static void Main() {
new Thread (Go).Start();
Go();
}
static void Go() {
lock (locker) {
if (!done) { Console.WriteLine ("Done"); done = true; }
}
}
}
Quando due thread si contendono contemporanemente un lock (in questo caso, locker), un thread rimane in attesa, o si blocca, fino a quando il lock ritorna disponibile. In questo caso, ci assicura che un solo thread alla volta può entrare nella sezione critica di codice, e "Done" verrà stampato solo una volta. Il codice che viene protetto in questa maniera – dall'indeterminazione ad un contesto multi-threding – viene chiamato thread-safe.
Mettere temporamente in attesa, o bloccare, è un fattore essenziale nella coordinazione, o sincronizzazione delle attività dei thread. Attendere a causa di un lock esclusivo è una ragione per cui un thread si può bloccare. Un altro caso invece se il thread decide di attendere, o Sleep per un periodo di tempo:
Thread.Sleep (TimeSpan.FromSeconds (30)); // Attendi per 30 secondi
Un thread può anche attendere la conclusione di un altro thread, chiamando il suo metodo Join:
Thread t = new Thread (Go); // Sia Go un metodo static
t.Start();
t.Join(); // Attendi (blocca) fino a quando t finisce
Un thread fintanto è bloccato, non consume risorse CPU
Come funziona il threading
Il Multithreading è gestito internamente da un thread scheduler, una funzionalità che il CLR tipicamente delega al sistema operativo. Un thread scheduler ci garantisce che tutti i thread attivi abbiamo un determinato tempo d'esecuzione, e che quei thread che sono in attesa o bloccati – per esempio – su un lock esclusivo, o sull'input dell'utente – non consumino tempo CPU.
Su una macchina a singolo-processore, un thread scheduler effettua il time-slicing – commutando rapidamente il flusso d'esecuzione fra i vari thread attivi. Un comportamento quindi "instabile", proprio come nel primo esempio, dove ogni blocco di caratteri X o Y corrisponde a una porzione di tempo assegnata al thread. Sotto Windows XP, un time-slice è un intervallo di tempo di 10 millisecondi– scelto per essere maggiore dell'overhead (costo di gestione) della CPU rispetto al context switch fra un thread e un altro.chosen such as to be much larger than the CPU overhead in actually switching context between one thread and another (che è tipicamente dell'ordine di pochi micro-secondi).
Su una macchina multi-processore,il multithreading viene implementato con un misto di time-slicing e concorrenza pura – dove differenti thread eseguono codice simultanemente su diverse CPU. E' certo anche che ci sarà un pò time-slicing, poichè il sistema operativo ha comunque necessità di servire i propri thread - come le altre applicazioni.
Un thread si dice preempted (con prelazione) quando la sua esecuzione viene interrotta da un fattore esterno come il partizionamento del time-slicing. Nella maggior parte dei casi, un thread non ha nessun controllo su come e dove venga interrotto.
Thread vs. Processi
Tutti i thread all'interno di una singola applicazione sono logicamente contenuti all'interno di un processo – l'unità del sistema operativo in cui l'applicazione gira.
I Thread hanno di sicuro molte somiglianze con i processi – per esempio, i processi sono tipicamente time-sliced [cioè il s.o. assegna a ogni thread partizioni di tempo, o quanti ben definiti] con gli altri processi sulla macchina allo stesso modo in cui lo sono i thread all'interno di una singola applicazione C#. La differenza chiave è che i processi sono completamente isolati dagli altri; i thread condividono la memoria (heap) con altri thread che girano nella stessa applicazione. Questo è quello che rendono i thread veramente utili: un thread può estrarre dei dati in maniera trasparente, mentre un altro visualizza gli stessi dati mentre arrivano.
Quando usare i thread
Una comune applicazione del multithreading è poter eseguire operazioni/task time-consuming (di lunga elaborazione) in maniera trasparente. Il thread principale continua a girare, mentre il worker thread lu esegue il suo job trasparente. Con applicazioni Windows Form o Windows Presentation Foundation, se il thread principale è legato all'elaborazione di un'operazione particolarmente lunga, i messaggi di tastiera e mouse non possono essere processati, e l'applicazione risulta bloccata. Per questa ragione, vale la pena eseguire task time-consuming tramite worker-thread persino quando il thread principale dovesse trattenere l'utente con un messaggio modale "Elaborazione in corso.. Attendere prego", in quei casi in cui il programma non può andare avanti fino a quando un particolare task non sia stato completato. Questo garantisce che l'applicazione non venga etichettata come "Non Risponde" dal sistema operativo, tentando l'utente a chiudere forzatamente il processo! L'approccio finestra modale consente anche di implementare un pulsante "Annulla", dato che il form modale resterà in attesa di ricevere eventi mentre il task attuale viene eseguito dal worker thread. La classe BackgroundWorker ci assiste proprio in questo pattern.
In caso di applicazioni non-UI, come un servizio di Windows, il multithreading ha particolarmente senso con un task time-consuming che è in attesa di una risposta di un altro computer(per esempio un application server, un database server o un client). Avere un worker thread significa che il thread che ha scatenato la richiesta è libero di fare qualcos'altro.
Un altro uso per il multithreading è nei metodi che eseguono calcoli piuttosto complessi. Tali metodi possono essere svolti molto più velocemente in computer multi-processore se il carico di lavoro viene suddiviso su thread multipli (il numero di processori si può verificare tramite la proprietà
Environment.ProcessorCount).
Un'applicazione C# diviene multi-thread in due modi: sia con la creazione esplicita e l'esecuzione di thread addizionali, sia usando una delle caratteristiche del framework .NET che implicitamente creano dei thread - come
BackgroundWorker,
thread pooling, un
threading timer, un server Remoting, o un Web Services oppure un'applicazione ASP.NET. In questi ultimi casi, altro non resta che abbracciare il multithreading. Un web server ASP.NET a thread singolo non sarebbe una gran cosa - anche se una cosa del genere fosse possibile! Fortunatamente, con gli applicaiotion server stateless (che non persistono lo stato), il multithreading è piuttosto semplice; al massimo l'unica preoccupazione sarebbe quella di fornire un appropriato meccanismo di locking per i dati cache in forma di variabili statiche.
Quando non usare i Thread
Il Multithreading ha anche i suoi svantaggi. Il più grande è che può portare alla creazione di applicazioni piuttosto complesse. I thread multipli in se stessi non creano complessità; è l'
interazione fra i thread che genera complessità. Non importa se l'interazione è intezionale oppure no, come può essere il risultato di lunghi cicli di sviluppo, così come una continua suscettibilità verso bachi intermittenti e non riproducibili. Per questo motivo, paga molto semplificare l'interazione in un contesto multi-thread - o non usare affatto il multithreading - a meno che non si abbia una particolare inclinazione al debuggin e alla riscrittura del codice.
Il Multithreading è associato ad un costo di cicli di CPU e risorse nell'allocazione e nello switch di thread, se usato eccessivamente. In particolare, quando è coinvolta una pesante operazione di I/O su disco, potrebbe essere molto più veloce avere uno o due worker thread che eseguono le operazioni in sequenza, piuttosto che avere una miriade di thread che eseguono un task nello stesso tempo. Più avanti descriveremo come implementare una
coda Produttore/Consumatore, che ci fornisce appunto questa funzionalità.
Creare e eseguire Thread
I Thread sono creati usando il costruttore della classe Thread, passandogli un delegato ThreadStart - indicando il metodo in cui l'esecuzione comincia. Ecco come il delegato ThreadStart viene definito:
public delegate void ThreadStart();
Il Thread parte chiamando il metodo Start. E continua fino a quando il suo metodo ritorna, cioè è a quel punto che il thread muore. Ecco un esempio, usando la sintassi estesa di C# per creare un delegato ThreadStart:
class ThreadTest {
static void Main() {
Thread t = new Thread (new ThreadStart (Go));
t.Start(); // Parte Go() sul nuovo thread.
Go(); // Contemporaneamente parte Go() sul thread principale.
}
static void Go() { Console.WriteLine ("hello!"); }
In questo esempio, il thread t esegue Go() - nello (quasi) stesso tempo, il thread principale chiama Go(). Il risultato sono due hello quasi istantanei:
hello!
hello!
Un thread può essere creato più convenientemente tramite la sintassi compatta per istanziare i delegati:
static void Main() {
Thread t = new Thread (Go); // Non è necessario usare ThreadStart
t.Start();
...
}
static void Go() { ... }
In questo caso, un delegato ThreadStart è determinato automaticamente dal compilatore, tramite inferenza. Un'altra scorciatoia è usare un metodo anonimo per inizializzare un thread:
static void Main() {
Thread t = new Thread (delegate() { Console.WriteLine ("Hello!"); });
t.Start();
}
Un thread ha una proprietà IsAlive che ritorna il booleano vero dopo che il metodo Start() è stato chiamato, fino a quando il thread non finisce.
Un thread, una volta terminato, non può essere fatto ripartire.
Passare dati a ThreadStart
Diciamo che, nell'esempio sopra, vogliamo differenziare meglio l'output di ogni singolo thread, avendo uno dei thread che stampa in maiuscolo. Potremmo ottenere ciò passando un flag al metodo Go: ma non possiamo usare il delegato ThreadStart poichè non accetta argomenti. Fortunatamente, il framework .Net definisce un altra versione del delegato chiamato ParameterizedThreadStart, che accetta un singolo object come argomento:
public delegate void ParameterizedThreadStart (object obj);
L'esempio precedente si può riscrivere:
class ThreadTest {
static void Main() {
Thread t = new Thread (Go);
t.Start (true); // == Go (true)
Go (false);
}
static void Go (object upperCase) {
bool upper = (bool) upperCase;
Console.WriteLine (upper ? "HELLO!" : "hello!");
}
hello!
HELLO!
In questo esemopio, il compilatore automaticamente inferisce un delegato ParameterizedThreadStart perchè il metodo Go accetta un singolo object come argomento. Allo stesso modo potremmo riscrivere:
Thread t = new Thread (new ParameterizedThreadStart (Go));
t.Start (true);
Una caratteristica di usare
ParameterizedThreadStart è che dobbiamo eseguire un cast sull'argomento
object per il tipo desiderato (in questo caso
bool) prima di usarlo. Inoltre, esiste solo una versione mono-argomento di questo delegato.
Un'alternativa è usare un metodo anonimo per chiamare un metodo ordinario:
static void Main() {
Thread t = new Thread (delegate() { WriteText ("Hello"); });
t.Start();
}
static void WriteText (string text) { Console.WriteLine (text); }
Il vantaggio è che il metodo target (in questo caso WriteTex) può accettare un numero qualsiasi di argomenti, e non è necessario nessun casting. Bisogna comunque tenere conto della semantica delle variabili outer (chiamate così quelle locali al metodo anonimo), come è evidente nel seguente esempio:
static void Main() {
string text = "Before";
Thread t = new Thread (delegate() { WriteText (text); });
text = "After";
t.Start();
}
static void WriteText (string text) { Console.WriteLine (text); }
After
I metodi anonimi aprono la possibilità grottesca di una interazione non voluta tramite le variabili outer, se esse vengono modificate da entrambe le parti dopo l'inizio del Thread. Un interazione invece intezionale (tramite i campi normali) va più che bene! Le variabili outer sono trattate meglio in sola-lettura una volta partita l'esecuzione del thread - a meno che non ci sia il desiderio di implementare una tecnica di locking.
Un altro sistema comune per passare dati a un thread è dando al Thread un metodo d'istanza piuttosto di un metodo statico. Le proprietà dell'oggetto istanza possono quindi dire al thread cosa fare, e riscrivendo l'esempio originale:
class ThreadTest {
bool upper;
static void Main() {
ThreadTest instance1 = new ThreadTest();
instance1.upper = true;
Thread t = new Thread (instance1.Go);
t.Start();
ThreadTest instance2 = new ThreadTest();
instance2.Go(); // Main thread – esegue con upper = false
}
void Go() { Console.WriteLine (upper ? "HELLO!" : "hello!"); }
Nominare i Threads
Un thread può essere nominato tramite la proprietà Name. Diventa di grande aiuto durante il debug: come Console.WriteLine è in grado di stampare un nome del thread, così Microsoft Visual Studio può mostrarlo nella barra di Debug Location. Il nome di un thread può essere impostato in qualsiasi momento - ma sono una volta - e i tentatativi successivi di modificarlo lanceranno un eccezione:
Al thread principale dell'applicazione può essere un nome - la proprietà statica CurrentThread accede al thread principale nell'esempio seguente:
class ThreadNaming {
static void Main() {
Thread.CurrentThread.Name = "main";
Thread worker = new Thread (Go);
worker.Name = "worker";
worker.Start();
Go();
}
static void Go() {
Console.WriteLine ("Hello from " + Thread.CurrentThread.Name);
}
}
Hello from main
Hello from worker
Thread foreground e thread background
Per default, i threads sono foreground, cioè tengono l'applicazione in vita fintanto uno di loro è in esecuzione. C# dà il supporto per i thread background, i quali non tengono in piedi l'applicazione per conto loro - terminando immediatamente una volta che tutti thread foreground sono terminati.
Passare un thread da foreground a background non cambia in alcun modo la sua priorità o lo stato rispetto allo scheduler della CPU.
La proprietà IsBackground controlla lo stato background, come nel seguente esempio:
class PriorityTest {
static void Main (string[] args) {
Thread worker = new Thread (delegate() { Console.ReadLine(); });
if (args.Length > 0) worker.IsBackground = true;
worker.Start();
}
}
Se il programmma viene invocato senza argomenti, il worker thread girerà in modalità foreground per default, in attesa sull'istruzione ReadLine, cioè in attesa della pressione del tasto Invio da parte dell'utente. Nel frattempo, il thread principale ritorna, ma l'applicazione è ancora in esecuzione poichè un thread foreground è ancora vivo.
Diversamente, se un argomento viene passato a Main(), il worker assume uno stato background e il programma ritorna quasi immediatamente come finisce il thread main - terminando la ReadLine.
Quando un thread background termina in questa maniera, qualsiasi blocco finally viene aggirato. E non è molto piacevole aggirare codice finally, è buona pratica attendere la fine di qualsiasi thread background prima di terminare un'applicazione - forse con timeout (si può ottenere chiamando un Thread.Join). Se per qualche ragione un thread non vuole saperne di terminare, si potrebbe tentare di ucciderlo, se questo dovesse fallire, abbandonare il thread, facendo in modo che esso termini quando termina il processo (loggare l'enigma avrebbe senso a questo punto!)
Avere thread come background può apportare dei benefici, proprio per la ragione che è sempre possibile avere l'ultima parola quando si tratta di terminare l'applicazione. Si consideri l'alternativa invece di - un thread foreground che non vuole morire - prevenire la terminazione l'applicazione. Un thread foreground orfano è particolarmente insidioso con un'applicazione Windows Form, poichè l'applicazione sembrerà terminare quando il thread main termina (almeno per l'utente) ma il suo processo continuerà a girare. Nel Task Manager di Windows, sarà sparito daltab delle applicazioni, sebbene il file dell'eseguibile sarà invece presente nel tab dei processi. A meno che l'utente individui e termini il task esplicitamente, esso continuerà a consumare risorse e potrebbe anche impedire o non far funzionare una nuova istanza dell'applicazione.
Un caso comune che un'applicazione non termina è, appunto, la presenza di thread foreground "dimenticati".
Priorità dei thread
La proprietà Priority di un thread determina quanto tempo di esecuzione ha a disposizione rispetto agli altri thread attivi nello stesso processo, secondo il seguente ordine:
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
Questo diventa particolarmente rilevante quando ci sono diversi thread attivi simultaneamente.
Innalzare la priorità di un thread non significa che funzioni a real-time, poichè è tuttavia limitato dalla priorità dell'applicazione all'interno del processo. Per farlo funzionare a real-time, la classe Process in System.Diagnostics espone la priorità del processo che può essere elevata come segue (non vi dirò come fare):
Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High;
ProcessPriorityClass.High è attualmente solo un gradino prima della priorità massima: Realtime. Impostare la priorità del processo a Realtime, significa che il sistema operativo non si avvalerà del diritto di prelazione sul processo stesso (appunto secondo lo scheduling non-preemptive). Se il vostro programma incontra un ciclo infinito, potete aspettarvi che il sistema operativo rimanga persino bloccato. Nemmeno il pulsante di accensione può salvarvi! Per questa ragione, High è generalmente considerata la più alta priorità disponibile.
Se l'applicazione real-time ha un'interfaccia utente, è poco raccomandabile elevare la priorità del processo poichè gli aggiornamenti dello schermo richiederebbero parecchie risorse CPU - rallentando l'intero pc, specie se la UI è complessa. (Benchè nel momento in cui scrivo, il programma di telefonia voip, Skype, se la cava bene, forse perchè ha una UI abbastanza semplice). Abbassare la priorità del thread principale - innalzando però la priorità del processo interessato contemporaneamente - ci assicura che il thread real-time non venga prelato a causa del ridisegno dello schermo, ma non previene il rallentamento della macchina, poichè il sistema operativo allocherà ancora più risorse per il processo. La soluzione ideale è far fare il lavoro real-time e quello per l'interfaccia utente in processi separati (con differente priorità), comunicando tramite Remoting o memoria condivisa. La memoria condivsa richiede il P/Invoke verso le API Win32 (tipo CreateFileMapping e MapViewOfFile).
Gestione delle eccezioni
Qualsiasi blocco try/catch/finally, quando viene creato un thread, non ha nessuna rilevanza quando parte l'esecuzione di un thread. Consideriamo il seguente programma:
public static void Main() {
try {
new Thread (Go).Start();
}
catch (Exception ex) {
// Non ci arriveremo mai qui!
Console.WriteLine ("Exception!");
}
static void Go() { throw null; }
}
L'istruzione try/catch in questo esempio è praticamente inutille, e il thread appena creato sarà ingolfato da una NullReferenceException non gestita. Questo comportamento ha senso quando si considera un thread con percorso di esecuzione indipendente. La soluzione è che imetodi entry dei thread abbiano i propri gestori delle eccezioni per ogni :
public static void Main() {
new Thread (Go).Start();
}
static void Go() {
try {
...
throw null; //questa eccezione verrà catturata sotto
...
}
catch (Exception ex) {
loggare l'eccezione e/o segnalare a un altro thread l'errore
...
}
A partire dal framework .NET 2.0, un'eccezione non gestita da un thread termina l'intera applicazione, questo significa che ignorarla non è generalemente una buona scelta. Quindi un blocco try/catch è obbligatorio per il metodo entry di ogni thread - quanto minimo in applicazioni di produzione - per evitare terminazioni non desiderate in caso di eccezioni non gestite. Questo può essere in qualche modo scomodo - specie per i programmatori Windows Form, che in genere usano il gestore "globale":
using System;
using System.Threading;
using System.Windows.Forms;
static class Program {
static void Main() {
Application.ThreadException += HandleError;
Application.Run (new MainForm());
}
static void HandleError (object sender, ThreadExceptionEventArgs e) {
Traccciare l'eccezione, quindi uscire o continuare...
}
}
L'evento Application.ThreadException interviene quando un'eccezione viene lanciata da codice come risultato di un messaggio di Windows (per esempio, un messaggio di tastiera, di mouse o un "paint") - in breve, tutto il codice di una tipica applicazione Windows Form. Sebbene tutto funzioni perfettamente, in realtà ci si appoggia ad un falso senso di sicurezza - cioè che tutte le eccezioni verranno catturate dal gestore centrale. Le eccezioni lanciate dai worker thread sono dei buoni esempi di eccezioni non catturate da Application.ThreadException (il codice all'interno del metodo Main ne è un altro - includendo anche il costruttore della form principale, che esegue prima del message loop).
Il framework .NET fornisce un evento a basso livello per la gestione globale delle eccezioni: AppDomain.UnhandledException. Questo evento interviene quando c'è un'eccezione non gestita in qualsiasi thread in qualsiasi tipo di applicazione (con o senza interfaccia utente). Nonostante offra un meccanismo da ultima spiaggia per tracciare queste eccezioni, non ci salva dall'arresto improvviso dell'applicazione o un modo per eliminare la finestra di dialogo.
In applicazioni di produzione, la gestione esplicita delle eccezioni viene richiesta su metodi che contegono gli entry point dei metodi. Potrebbe essere conveniente invece usare una classe wrapper o una classe helper per compiere questo lavoro, come BackgroundWorker (discusso nella Parte 3).