La parte introduttiva e conclusiva di questo articolo proviene dal libro C# 5(ISBN 978-8820352530), la seconda parte invece dalla documentazione ufficiale.
1. Introduzione
Quando richiediamo lβesecuzione di un programma, il sistema operativo crea unβistanza di un particolare oggetto del kernel chiamato process, a cui assegna un ben definito (e isolato) spazio di indirizzamento in memoria.
Un processo, di per sΓ©, non Γ¨ in grado di eseguire alcun codice e svolge il compito di puro e semplice contenitore di quelle che potremmo definire come le entitΓ funzionali elementari del sistema operativo: i thread.
Ogni processo dispone di almeno un thread, chiamato primary thread, al termine del quale esso viene distrutto, liberando lo spazio di memoria e le risorse a esso assegnate.
Il normale ciclo di vita di unβapplicazione consiste nellβesecuzione sequenziale di numerosi blocchi di codice richiamati dal thread principale, che a loro volta possono richiamare ulteriori blocchi di codice.
Quando questo avviene, il metodo chiamante risulta ovviamente bloccato fintanto che la routine invocata non giunge al termine: si tratta di un operazione non sempre percorribile in quanto appesantirebbe notevolmente lβutilizzo delle applicazioni.
Fortunatamente ogni thread ha la possibilitΓ di assegnare ad un thread secondario lβesecuzione di un metodo; in questo caso, la chiamata a questβultimo ritorna immediatamente il controllo al thread chiamante e i due blocchi di codice possono effettivamente essere eseguiti in parallelo.
2. Esempio applicativo
La classe System.Threading.Thread
viene usata per lavorare con i thread.
Quando un programma viene lanciato, viene creato automaticamente il corrispettivo thread principale, mentre i thread che vengono creati dallo sviluppatore tramite la classe Thread sono chiamati thread figli.
Nellβesempio riportato di seguito, viene illustrato come creare un thread parallelo ausiliario, da utilizzare per lβelaborazione in parallelo con il thread primario.
Vogliamo creare una classe chiamata Worker
che contiene il metodo DoWork
che verrΓ eseguito dal nostro thread di lavoro. Questo Γ¨ essenzialmente la funzione Main del thread.
Quando verrΓ eseguito, il thread di lavoro chiamerΓ questo metodo e terminerΓ automaticamente quando il metodo verrΓ restituito.
public void DoWork()
{
while (!_shouldStop)
{
Console.WriteLine("worker thread: working...");
}
Console.WriteLine("worker thread: terminating gracefully.");
}
La classe Worker
contiene inoltre un altro metodo utilizzato per indicare che DoWork
deve essere restituito, questo metodo, denominato RequestStop
, Γ¨ analogo al seguente
public void RequestStop()
{
_shouldStop = true;
}
Il metodo RequestStop
assegna semplicemente lβattributo _shouldStop
a true.
PoichΓ© questo attributo Γ¨ controllato dal metodo DoWork
, si ottiene lβeffetto indiretto di causare la restituzione di DoWork
terminando in questo modo il thread di lavoro.
Γ tuttavia importante tenere presente che DoWork
e RequestStop
verranno eseguiti da thread differenti.
DoWork
viene eseguito dal thread di lavoro, mentre RequestStop
viene eseguito dal thread primario, quindi lβattributo _shouldStop
viene dichiarato volatile
, come riportato di seguito
private volatile bool _shouldStop;
La parola chiave volatile
avvisa il compilatore che piΓΉ thread accederanno al membro dati _shouldStop
e che pertanto non deve formulare ipotesi di ottimizzazione sullo stato di questo membro.
Il modificatore volatile
Γ¨ utilizzato in genere per un campo al quale accedono piΓΉ thread senza ricorrere allβistruzione lock
per la serializzazione dellβaccesso.
Lβutilizzo di questo modificatore Γ¨ cosΓ¬ comodo perchΓ© _shouldStop Γ¨ un valore bool. Se tuttavia questo membro dati fosse un oggetto complesso, lβaccesso da piΓΉ thread genererebbe un danneggiamento dei dati.
Prima di creare il thread di lavoro, la funzione Main
crea un oggetto Worker
e unβistanza di Thread
.
Lβoggetto thread viene configurato per utilizzare il metodo Worker.DoWork
passando al costruttore Thread
un riferimento a questo metodo, come riportato di seguito
Worker workerObject = new Worker();
Thread workerThread = new Thread(workerObject.DoWork);
A questo punto, anche se lβoggetto thread esiste ed Γ¨ configurato, questo non Γ¨ ancora stato creato, per fare ciΓ² Γ¨ necessario lanciare il metodo Start
workerThread.Start();
A questo punto il sistema avvia lβesecuzione del thread di lavoro, ma in modo asincrono rispetto al thread primario. La funzione Main
continua infatti ad eseguire immediatamente il codice mentre il thread di lavoro viene sottoposto contemporaneamente a inizializzazione.
Per evitare che Main
tenti di terminare il thread di lavoro prima che venga eseguito, la funzione Main
esegue un ciclo finchΓ© la proprietΓ IsAlive
dellβoggetto thread di lavoro non viene impostata su true:
while (!workerThread.IsAlive);
Successivamente il thread primario viene interrotto brevemente con una chiamata a Sleep
.
In questo modo la funzione DoWork
del thread di lavoro eseguirΓ il ciclo allβinterno del metodo DoWork
per alcune iterazioni prima che la funzione Main
esegua altri comandi
Thread.Sleep(1);
Trascorso un millisecondo, Main
segnala allβoggetto thread di lavoro che deve terminare utilizzando il metodo Worker.RequestStop
descritto in precedenza
workerObject.RequestStop();
Γ inoltre possibile terminare un thread da un altro thread utilizzando una chiamata a Abort
. In questo modo il thread interessato viene terminato in modo forzato anche se non ha completato lβattivitΓ e non consente la pulitura delle risorse.
Infine la funzione Main
chiama il metodo Join
sullβoggetto thread di lavoro.
Tramite questo metodo il thread corrente si blocca oppure attende finchΓ© non termina il thread rappresentato dallβoggetto.
Pertanto Join
non verrΓ restituito finchΓ© non viene terminato, il thread di lavoro
workerThread.Join();
Il metodo Join accetta come parametro anche un timeout, che rappresenta il tempo massimo di attesa al termine del quale proseguire con lβapplicazione.
Di seguito Γ¨ riportato lβesempio completo.
using System;
using System.Threading;
public class Worker
{
// Questo metodo viene passato a costruttore come delegato
// alla inizializzazione della classe Thread
public void DoWork()
{
while (!_shouldStop)
{
Console.WriteLine("worker thread: working...");
}
Console.WriteLine("worker thread: terminating gracefully.");
}
public void RequestStop()
{
_shouldStop = true;
}
// Volatile permette di evitare il lock su attributi booleani
// che vengono acceduti da piΓΉ thread
private volatile bool _shouldStop;
}
public class WorkerThrea[[DEX]]ample
{
static void Main()
{
Worker workerObject = new Worker();
Thread workerThread = new Thread(workerObject.DoWork);
workerThread.Start();
Console.WriteLine("main thread: Starting worker thread...");
// Aspetta fino a che il thread non viene ativato
while (!workerThread.IsAlive);
// Aspetto 1ms in modo che il worker faccia qualcosa
Thread.Sleep(1);
workerObject.RequestStop();
// Blocco il thread corrente fino a che il worker
// non viene terminato con successo
workerThread.Join();
Console.WriteLine("main thread: Worker thread has terminated.");
}
}
Lβoutput di questo programma Γ¨ il seguente
main thread: starting worker thread...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: working...
worker thread: terminating gracefully...
main thread: worker thread has terminated
3. Il ThreadPool
4. Modello di programmazione asincrono
Il comando BeginInvoke
permette di eseguire delegate in maniera asincrona, in quanto questo ritorna immediatamente il controllo al thread chiamante e parallelamente fa partire il codice del delegato.
Internamente il BeginInvoke
utilizza il threadPool del CLR.
Ci sono due fondamentali differenze tra usare questo metodo e le classi Thread
o ThreadPool
illustrate in precedenza:
- il passaggio di parametri al metodo asincrono avviene in maniera tipizzata, mentre con Thread o ThreadPool lβeventale parametro Γ¨ sempre object;
- i delegate risultano particolarmente** comodi per intercettare il termine dellβelaborazione parallela** e, in particolare, per recuperare il risultato.
Esistono tre diverse modalitΓ per gestire lβesecuzione parallela e recuperarne il risultato, descritte di seguito.
4.1 Utilizzo del metodo EndInvoke
Insieme al metodo BeingInvoke
indicato in precedenza, ogni delegato espone anche il metodo EndInvoke
che puΓ² essere utilizzato per attendere la conslusione dellβoperazione asincrona e recuperarne il risultato.
BeginInvoke
restituisce un oggetto IAsyncResult
che puΓ² essere utilizzato per monitorare lβavanzamento della chiamata asincrona ed inoltre viene passato come parametro al metodo EndInvoke
.
EndInvoke
blocca effetivamente in thread in esecuzione fino al termine dellβoperazione asincrona, consentendoci quindi di sincronizzare i due thread.
Qualora il delegate preveda un valore di ritorno, EndInvoke
lo restituisce come output.
4.2 Utilizzo di IAsyncResult
e polling
Il metodo BeginInvoke
restituisce un oggetto che implementa lβinterfaccia di tipo IAsyncResult
.
La proprietΓ piΓΉ importante di questo oggetto Γ¨ AsyncWaitHandle
che restituisce un oggetto di tipo WaitHandle
, analogo al ManualResetEvent
indicato in precedenza.
Tramite questo metodo Γ¨ possibile implementare un algoritmo di polling per verificare quando un thread ha concluso il suo lavoro nel seguente modo
while(!asyncResult.isCompleted)
{
asyncResult.AsyncWaitHandle.WaitOne(200);
}
4.3 Utilizzare un metodo di callback
In alcune occasioni vi Γ¨ la necessitΓ di evitare che sia il metodo chiamante a gestire il termine del thread; in questo caso possiamo configurare il delegate in modo che esegua un metodo di callback, passando il metodo come secondo parametro al BeginInvoke
.
La firma del metodo di callback deve rispettare quella del delegate AsyncCalback
, quindi deve ritornare void
e prendere come unico parametro in ingresso un oggetto di tipo IAsyncResult
.
5. Operazioni cross-thread con Windows Form
5.1 Problema
Accedere a dei controlli Windows Forms (quindi andare a eseguire operazioni nel thread di gestione dellβI/O) non Γ¨ intrinsecamente thread-safe, infatti potrei avere delle inconsistenze nei dati o perfino dei deadlock.
Eβ insicuro chiamare un controllo da un thread che non sia colui che ha creato e gestisce il controllo, senza usare il metodo BeginInvoke
.
Nellβesempio seguente ho una chiamata non thread safe:
'Quando clicco un pulsante creo un thread che chiama un controllo Windows Form in modo insicuro
Private Sub setTextUnsafeBtn_Click(ByVal sender As Object, ByVal e As EventArgs) Handles setTextUnsafeBtn.Click
Me.demoThread = New Thread(New ThreadStart(AddressOf Me.ThreadProcUnsafe))
Me.demoThread.Start()
End Sub
' Effettuo una modifica all'I/O da un thread esterno
Private Sub ThreadProcUnsafe()
Me.textBox1.Text = "This text was set unsafely."
End Sub
Il debugger .NET rileva queste situazioni lanciando un eccezione InvalidOperationException
con il messaggio:
Control control name accessed from a thread other than the thread it was created on.
5.2 Soluzione
Per chiamare un controllo Windows Form in modo thread-safe devo effettuare i seguenti controlli:
- Controllare la proprietΓ
InvokeRequired
della classe - Se
InvokeRequired
ritorna true, chiamare il metodoBeginInvoke
con un delegate con la stessa firma del metodo attuale - Se
InvokeRequired
ritorna false, chiamare il metodo direttamente
'Quando clicco un pulsante creo un thread che chiama un controllo Windows Form in modo, questa volta, sicuro
Private Sub setTextUnsafeBtn_Click(ByVal sender As Object, ByVal e As EventArgs) Handles setTextUnsafeBtn.Click
Me.demoThread = New Thread(New ThreadStart(AddressOf Me.ThreadProcUnsafe))
Me.demoThread.Start()
End Sub
' Effettuo una modifica all'I/O da un thread esterno
Private Sub ThreadProcUnsafe()
Me.SetText("This text was set safely.")
End Sub
Delegate Sub SetTextDelegate(text As String)
Private Sub SetText(ByVal text As String)
If Me.textBox1.InvokeRequired Then
Me.BeginInvoke(New SetTextDelegate(AddressOf SetText), New Object() {text})
Else
Me.textBox1.Text = text
End If
End Sub
InvokeRequired
compara il thread ID del thread chiamante con il thread ID del thread corrente: se questi thread sono differenti ritorna True
.
Se InvokeRequired
ritorna True
sto quindi facendo una chiamata cross-thread: in questo caso il metodo crea una nuova istanza del delagate che permette di autochiamarsi in modo asincrono dal thread corretto.
6. Tips generici
Questo articolo Γ¨ una libera traduzione di questo blog post
6.1 I thread condividono le variabili se hanno una reference comune allβistanza dello stesso oggetto
Consideriamo il seguente esempio:
class SomeClass
{
private bool _isWorkDone;
static void Main(string[] args)
{
SomeClass someClass = new SomeClass();
// Passso il metodo dell'istanza someClass
Thread newThread = new Thread(someClass.DoWork);
newThread.Start();
// Chiamo il metodo dalla stessa istanza del thread
someClass.DoWork();
Console.Read();
}
void DoWork()
{
if (!_isWorkDone)
{
_isWorkDone = true;
Console.WriteLine("Work done");
}
}
}
Il risultato di questo codice Γ¨ la scritta βWork doneβ sullo schermo.
Come si puΓ² vedere dal codice, il metodo DoWork()
viene chiamato da entrambi i thread a partire dalla stessa istanza di SomeClass
: come risultato, dato che il campo _isWorkDone
non Γ¨ statico, questo viene condiviso dai due thread.
Conseguentemente, βWork doneβ viene printato a schermo una volta sola.
6.2 Il blocco finally nei thread in background non viene eseguito quando il processo termina
Consideriamo il seguente esempio:
class SomeClass
{
static void Main(string[] args)
{
SomeClass someClass = new SomeClass();
Thread backgroundThread = new Thread(someClass.DoWork);
backgroundThread.Start();
Console.WriteLine("Closing the program....");
}
void DoWork()
{
try
{
Console.WriteLine("Doing some work...");
Thread.Sleep(1000);
}
finally
{
Console.WriteLine("This should be always executed");
}
}
}
Questo codice fornisce:
Doing some work...
Closing the program...
Questo esempio dimostra come quando il thread principale termina la sua esecuzione, il campo finally
del thread in background non viene eseguito.
Non considerare questa eccezione puΓ² portare numero di problemi quando ho lavori di dispose() nel blocco finally di un thread, che non verrebbero lanciati portando, in questo caso, a dei memory leak difficili da individuare
6.3 I valori trovati da lambda espressioni sono anchβessi condivisi
Consideriamo il seguente codice:
class SomeClass
{
static void Main(string[] args)
{
for (int i = 0; i < 10; i++)
{
Thread thread = new Thread(()=> Console.Write(i));
thread.Start();
}
Console.Read();
}
}
Noi ci aspettiamo un risultato come 0123456789.
Il risultato ottenuto Γ¨ invece assolutamente non deterministico!
Il trucco Γ¨ che la variabile i si riferisce alla stessa locazione di memoria della variabile iterata nellβfor
, che quindi continua a cambiare.
La soluzione Γ¨ utilizzare una variabile temporanea:
for (int i = 0; i<= 10; i++)
{
int temp = i ;
Thread thread = new Thread(()=> Console.Write(temp));
thread.Start();
}
6.4 Un thread non Γ¨ influenzato dalla presenza di un try catch esterno alla sua creazione
Consideriamo il seguente codice:
class SomeClass
{
static void Main(string[] args)
{
try
{
Thread thread = new Thread( ()=> Divide(10,0));
thread.Start();
}
catch (Exception ex)
{
Console.WriteLine("An exception occured");
}
}
static void Divide(int x, int y)
{
int z = x / y;
}
}
Lβeccezione di divisione per zero non verrΓ catturata dal catch del main
, ma rimarrΓ ncaught
portando allo spegnimento del programma.
Il modo migliore per risolvere questa cosa Γ¨ banalmente spostare il blocco try catch
allβinterno del metodo del thread.