La sincronizzazione dei thread è l’esecuzione simultanea di due o più thread che condividono risorse critiche. I thread devono essere sincronizzati per evitare conflitti. Vi sono due modi principali per fare sì che i thread attendino una determinata condizione in C#, i lock e i WaitHandles. Vediamoli entrambi.
Sincronizzazione con lock
La sincronizzazione di thread mediante lock è la più immediata in quanto permette, con poche righe di codice, di garantire l’accesso esclusivo ad una risorsa, tempo fa ho scritto un articolo a riguardo che puoi recuperare qui: I lock in C#.
Sincronizzazione con i ManualResetEvent
ManualResetEvent, come AutoResetEvent, è una classe utilissima per la sincronizzazione di thread in .NET. Per thread non intendo solo la classe System.Threading.Thread ma anche Task o qualsiasi altro elemento asincrono.
Bisogna pensare il ManualResetEvent come una variabile booleana, con il vantaggio che può essere aspettata in maniera bloccante per un determinato periodo di tempo. In particolare mentre tale variabile è falsa i thread che la aspettano vengono bloccati, quando è vera invece al contrario vengono sbloccati.
I seguenti metodi sono sia di ManualResetEvent che di AutoResetEvent in quanto entrambi ereditano dalla stessa classe astratta WaitHandle.
La sincronizzazione dei thread è molto importante soprattutto quando essi agiscono su una stessa risorsa ed è necessario evitare conflitti. Grazie all’utilizzo di queste classi è possibile fare in modo che i thread aspettino sempre il momento giusto prima di agire.
Inizializzazione
L’inizializzazione è semplice e ne identifica lo stato iniziale (tipicamente false, quindi bloccante)
ManualResetEvent manualResetEvent = new ManualResetEvent(false);
Set
Il metodo Set() permette di impostare la variabile booleana associata al ManualResetEvent a true. I thread in attesa saranno sbloccati.
Reset
Il metodo Reset() permette di impostare la variabile booleana associata al ManualResetEvent a false. I thread in attesa saranno bloccati.
WaitOne
WaitOne è il metodo fondamentale in quanto permette di aspettare il ManualResetEvent fino a che questo non diventi vero (quindi ne viene chiamato il metodo Set()) a meno di un timeout opzionale passato come parametro in ingresso.
Quindi il seguente codice
bool isSignalled = manualResetEvent.WaitOne();
aspetta all’infinito che venga chiamato il metodo Set() dell’oggetto.
Il seguente codice invece
bool isSignalled = manualResetEvent.WaitOne(500);
attende un massimo di 500ms. Una volta superati il metodo ritorna false e procede.
Questo metodo è anche utilizzato per ottenere la variabile booleana sottesa alla classe senza aspettare.
var isSignaled = manualResetEvent.WaitOne(0);
La variabile isSignaled fornirà true qualora la variabile sottesa sia true, false altrimenti.
Esempio
Vediamo ora un utilizzo pratico dei ManualResetEvent: vogliamo creare task paralleli che partano a lavorare esattamente nello stesso istante. Simuliamo di avere problemi di prestazioni e necessità di una partenza veramente sincronizzata tra i due metodi, quindi non posso usare Parallel.Invoke, ThreadPool o metodi analoghi ma ho bisogno che i thread siano già creati, pronti e debbano solo partire.
Per esempio creo il metodo Runner che quando viene lanciato aspetta che un ManualResetEvent passato in ingresso sia true per poi partire con l’esecuzione.
static void Runner(ManualResetEvent signal, int id)
{
Console.WriteLine($"Runner [{id}] is ready");
signal.WaitOne();
Console.WriteLine($"Runner [{id}] start!");
Thread.Sleep(1000); // operation
Console.WriteLine($"Runner [{id}] finished!");
}
Ora posso creare n thread che fanno partire tale metodo. Ognuno avrà in ingresso il ManualResetEvent, inoltre, opzionalmente, una volta terminati entrambi i task tale segnale verrà impostato a false
using var signal = new ManualResetEvent(false);
var t1 = Task.Run(() =>
{
Runner(signal, 1);
});
var t2 = Task.Run(() =>
{
Runner(signal, 2);
});
Task.WhenAll(t1, t2).ContinueWith(task => signal.Reset());
Una volta fatto ciò i thread sono pronti ed in attesa, attendono il Set() del ManualResetEvent per poter partire.
signal.Set();
Ora i due thread sono partiti esattamente nello stesso momento senza alcun delay. Questo codice fornisce:
Runner [2] is ready
Runner [1] is ready
Runner [2] start!
Runner [1] start!
Runner [1] finished!
Runner [2] finished!
Il codice completo lo trovate qui: https://gist.github.com/Rowandish/99bff26bbbb496c3d714b6950cf00d9b
Esempio 2
Questi segnali possono essere anche utilizzati per far fare ad un thread un determinato tipo di lavoro fino a che il segnale è false, mentre un altro tipo quando diventa true.
Per esempio consideriamo il seguente codice
static void TwoWorkThread()
{
while(!signal.WaitOne(0))
{
Console.WriteLine("First work");
Thread.Sleep(1000);
if (!signal.WaitOne(0))
break;
// Another work
Thread.Sleep(1000);
}
Console.WriteLine("Exit work");
}
In questo metodo viene eseguito il ciclo FirstWork fino a che non viene chiamato il Set() del ManualResetEvent. Dato che il controllo su questo valore avviene solo nella definizione del while, qualora il metodo sia molto lungo è possibile effettuare dei controlli intermedi nel metodo, in modo da uscire subito senza aspettare di ritornare sul while:
if (!signal.WaitOne(0))
break;
AutoResetEvent
La classe AutoResetEvent ha lo stesso comportamento del ManualResetEvent con una leggera differenza: quando viene chiamato il metodo Set() solo un thread si sbloccherà: il primo metodo che supererà WaitOne() imposterà a false il flag interno. In pratica è come se fosse eseguito un Reset() della classe al primo thread sbloccato. WaitOne() e Reset() in questo caso sono una singola operazione atomica.
Come specifica questa risposta su SO la differenza tra i due è come la differenza tra una porta e un tornello. La porta, una volta aperta, permette a tutti di passare fino a che qualcuno non la chiude. Il tornello invece permette ad una sola persona di passare richiudendosi subito dopo.
Considerando lo stesso esempio di prima, se sostituiamo ManualResetEvent con AutoResetEvent quello che otteniamo è
Runner [1] is ready
Runner [2] is ready
Runner [1] start!
Runner [1] finished!
Il runner numero 2 non parte mai in quanto il runner 1 quando si è sbloccato ha anche effettuato implicitamente un Reset().