La sincronizzazione di thread in C#

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().

Indice

Share
Ultimi articoli

Codice Pragmatico

Contatti

Per informazioni, dubbi o consulenze non esitate a contattarmi.

Lascia un messaggio

Ricevi le ultime news

Iscrivi alla newsletter

Solo articoli interessanti, promesso ;)