Procedura: sincronizzare un thread producer e un thread consumer (Guida per programmatori C#)
Aggiornamento: novembre 2007
Nell'esempio riportato di seguito viene illustrata la sincronizzazione tra il thread primario e due thread di lavoro utilizzando la parola chiave lock e le classi AutoResetEvent e ManualResetEvent. Per ulteriori informazioni, vedere la classe Istruzione lock (Riferimenti per C#).
In questo esempio vengono creati due thread ausiliari, o di lavoro. Un thread produce elementi e li archivia in una coda generica non thread-safe. Per ulteriori informazioni, vedere Queue<T>. L'altro thread utilizza gli elementi di questa coda. Il thread primario visualizza inoltre periodicamente il contenuto della coda, in modo che alla coda accedano tre thread. La parola chiave lock consente di sincronizzare l'accesso alla coda in modo da garantire che lo stato della coda non venga danneggiato.
Oltre a impedire l'accesso simultaneo che utilizza la parola chiave lock, viene fornita un'ulteriore sincronizzazione mediante due oggetti evento. Uno consente di segnalare che i thread di lavoro devono terminare, mentre l'altro viene utilizzato dal thread producer per segnalare al thread consumer che alla coda è stato aggiunto un nuovo elemento. Questi due oggetti evento vengono incapsulati in una classe denominata SyncEvents. In questo modo gli eventi passano facilmente agli oggetti che rappresentano i thread consumer e producer. La classe SyncEvents viene definita nel seguente modo:
public class SyncEvents
{
public SyncEvents()
{
_newItemEvent = new AutoResetEvent(false);
_exitThreadEvent = new ManualResetEvent(false);
_eventArray = new WaitHandle[2];
_eventArray[0] = _newItemEvent;
_eventArray[1] = _exitThreadEvent;
}
public EventWaitHandle ExitThreadEvent
{
get { return _exitThreadEvent; }
}
public EventWaitHandle NewItemEvent
{
get { return _newItemEvent; }
}
public WaitHandle[] EventArray
{
get { return _eventArray; }
}
private EventWaitHandle _newItemEvent;
private EventWaitHandle _exitThreadEvent;
private WaitHandle[] _eventArray;
}
La classe AutoResetEvent viene utilizzata per l'evento "new item", perché si desidera che venga reimpostato automaticamente ogni volta che il thread consumer risponde a tale evento. In alternativa, la classe ManualResetEvent viene utilizzata per l'evento "exit", perché si desidera che più thread rispondano quando viene segnalato questo evento. Se invece si utilizza AutoResetEvent, l'evento torna a uno stato non segnalato non appena un thread risponde. L'altro thread non risponde e, in questo caso, non viene terminato.
La classe SyncEvents crea i due eventi e li archivia in due diversi formati: come EventWaitHandle, che corrisponde alla classe base per AutoResetEvent e ManualResetEvent, e in una matrice basata su WaitHandle. Come riportato nella descrizione dei thread consumer, questa matrice è necessaria affinché il thread consumer possa rispondere ai due eventi.
I thread consumer e producer sono rappresentati dalle classi denominate Consumer e Producer. Entrambe definiscono un metodo chiamato ThreadRun. Questi metodi fungono da punti di ingresso per i thread di lavoro creati dal metodo Main.
Il metodo ThreadRun definito dalla classe Producer è analogo al seguente:
// Producer.ThreadRun
public void ThreadRun()
{
int count = 0;
Random r = new Random();
while (!_syncEvents.ExitThreadEvent.WaitOne(0, false))
{
lock (((ICollection)_queue).SyncRoot)
{
while (_queue.Count < 20)
{
_queue.Enqueue(r.Next(0,100));
_syncEvents.NewItemEvent.Set();
count++;
}
}
}
Console.WriteLine("Producer thread: produced {0} items", count);
}
Questo metodo viene eseguito in ciclo finché non viene segnalato l'evento "exit thread". Lo stato di questo evento viene testato con il metodo WaitOne utilizzando la proprietà ExitThreadEvent definita dalla classe SyncEvents. In questo caso, lo stato dell'evento viene controllato senza bloccare il thread corrente, perché il primo argomento utilizzato con il metodo WaitOne è zero, a indicare che il metodo deve essere restituito immediatamente. Se WaitOne restituisce true, viene segnalato l'evento in questione. In questo caso viene restituito il metodo ThreadRun, in seguito al quale il thread di lavoro che lo esegue viene terminato.
Finché non viene segnalato l'evento "exit thread", il metodo Producer.ThreadStart cerca di mantenere 20 elementi nella coda. Un elemento non è altro che un numero intero compreso tra zero e 100. L'insieme deve essere bloccato prima di aggiungere nuovi elementi per impedire ai thread consumer e primario di accedervi contemporaneamente. Questo risultato viene ottenuto mediante la parola chiave lock. L'argomento passato a lock è il campo SyncRoot esposto mediante l'interfaccia ICollection. Questo campo viene fornito appositamente per la sincronizzazione dell'accesso ai thread. All'insieme viene concesso l'accesso esclusivo per le istruzioni contenute nel blocco di codice che segue lock. Per ogni nuovo elemento aggiunto dal producer alla coda, viene effettuata una chiamata al metodo Set sull'evento "new item". In questo modo si segnala al thread consumer di uscire dallo stato di sospensione per elaborare il nuovo elemento.
L'oggetto Consumer definisce inoltre un metodo denominato ThreadRun. Analogamente alla versione producer di ThreadRun, questo metodo viene eseguito da un thread di lavoro creato dal metodo Main. La versione consumer di ThreadStart deve tuttavia rispondere a due eventi. Il metodo Consumer.ThreadRun è analogo al seguente:
// Consumer.ThreadRun
public void ThreadRun()
{
int count = 0;
while (WaitHandle.WaitAny(_syncEvents.EventArray) != 1)
{
lock (((ICollection)_queue).SyncRoot)
{
int item = _queue.Dequeue();
}
count++;
}
Console.WriteLine("Consumer Thread: consumed {0} items", count);
}
Questo metodo utilizza WaitAny per bloccare il thread consumer finché non viene segnalato uno degli handle di attesa della matrice fornita. In questo caso, la matrice contiene due handle, uno che termina i thread di lavoro e l'altro che indica che è stato aggiunto un nuovo elemento all'insieme. WaitAny restituisce l'indice dell'evento segnalato. L'evento "new item" è il primo della matrice, in modo che un indice pari a zero indica un nuovo elemento. In questo caso, verificare la presenza di un indice 1, che indica l'evento "exit thread", utilizzato per determinare se questo metodo continua a utilizzare elementi. Se viene segnalato l'evento "new item", si otterrà l'accesso esclusivo all'insieme con lock e verrà utilizzato il nuovo elemento. Poiché in questo esempio vengono prodotti e utilizzati migliaia di elementi, non viene visualizzato ciascun elemento utilizzato. Utilizzare invece Main per visualizzare periodicamente il contenuto della coda, come verrà illustrato più avanti.
All'inizio del metodo Main viene creata la coda il cui contenuto verrà prodotto e utilizzato, nonché un'istanza di SyncEvents esaminata in precedenza:
Queue<int> queue = new Queue<int>();
SyncEvents syncEvents = new SyncEvents();
Successivamente Main configura gli oggetti Producer e Consumer da utilizzare con i thread di lavoro. In questo passaggio non vengono tuttavia creati né avviati i thread di lavoro effettivi:
Producer producer = new Producer(queue, syncEvents);
Consumer consumer = new Consumer(queue, syncEvents);
Thread producerThread = new Thread(producer.ThreadRun);
Thread consumerThread = new Thread(consumer.ThreadRun);
Si noti che l'oggetto coda e l'oggetto evento di sincronizzazione vengono passati ai thread Consumer e Producer come argomenti del costruttore. In questo modo a entrambi gli oggetti vengono fornite le risorse condivise necessarie per eseguire le rispettive attività. Vengono quindi creati due nuovi oggetti Thread utilizzando il metodo ThreadRun per ogni oggetto come argomento. Quando verrà avviato, ciascun thread di lavoro utilizzerà questo argomento come punto di ingresso per il thread.
Successivamente Main avvia i due thread di lavoro con una chiamata al metodo Start, come riportato di seguito:
producerThread.Start();
consumerThread.Start();
A questo punto vengono creati i due nuovi thread di lavoro e viene iniziata l'esecuzione asincrona, in modo indipendente rispetto al thread primario che esegue il metodo Main. In realtà, l'operazione successiva effettuata da Main è la sospensione del thread primario con una chiamata al metodo Sleep. Il metodo sospende il thread in esecuzione per un determinato numero di millisecondi. Una volta trascorso questo intervallo, il metodo Main viene riattivato e a questo punto visualizza il contenuto della coda. Main ripete questa operazione per quattro iterazioni, come riportato di seguito:
for (int i=0; i<4; i++)
{
Thread.Sleep(2500);
ShowQueueContents(queue);
}
Infine, Main segnala che i thread di lavoro devono terminare richiamando il metodo Set dell'evento "exit thread" e quindi chiama il metodo Join su ciascun thread di lavoro per bloccare il thread primario finché ogni thread di lavoro non risponde all'evento e viene terminato.
Un ultimo esempio di sincronizzazione dei thread è rappresentato dal metodo ShowQueueContents. Questo metodo, analogamente ai thread consumer e producer, utilizza la parola chiave lock per ottenere accesso esclusivo alla coda. In questo caso, tuttavia, l'accesso esclusivo è molto importante, perché ShowQueueContents esegue l'enumerazione in tutto l'insieme. L'enumerazione in un insieme è un operazione particolarmente soggetta al danneggiamento di dati da parte di operazioni asincrone, perché implica l'accesso al contenuto dell'intero insieme.
Si noti che, essendo chiamato da Main, il metodo ShowQueueContents viene eseguito dal thread primario. Questo significa che, quando ottiene l'accesso esclusivo alla coda di elementi, tale metodo blocca l'accesso alla coda sia ai thread producer che consumer. ShowQueueContents blocca la coda e ne enumera il contenuto:
private static void ShowQueueContents(Queue<int> q)
{
lock (((ICollection)q).SyncRoot)
{
foreach (int item in q)
{
Console.Write("{0} ", item);
}
}
Console.WriteLine();
}
Di seguito viene fornito l'esempio completo.
Esempio
using System;
using System.Threading;
using System.Collections;
using System.Collections.Generic;
public class SyncEvents
{
public SyncEvents()
{
_newItemEvent = new AutoResetEvent(false);
_exitThreadEvent = new ManualResetEvent(false);
_eventArray = new WaitHandle[2];
_eventArray[0] = _newItemEvent;
_eventArray[1] = _exitThreadEvent;
}
public EventWaitHandle ExitThreadEvent
{
get { return _exitThreadEvent; }
}
public EventWaitHandle NewItemEvent
{
get { return _newItemEvent; }
}
public WaitHandle[] EventArray
{
get { return _eventArray; }
}
private EventWaitHandle _newItemEvent;
private EventWaitHandle _exitThreadEvent;
private WaitHandle[] _eventArray;
}
public class Producer
{
public Producer(Queue<int> q, SyncEvents e)
{
_queue = q;
_syncEvents = e;
}
// Producer.ThreadRun
public void ThreadRun()
{
int count = 0;
Random r = new Random();
while (!_syncEvents.ExitThreadEvent.WaitOne(0, false))
{
lock (((ICollection)_queue).SyncRoot)
{
while (_queue.Count < 20)
{
_queue.Enqueue(r.Next(0,100));
_syncEvents.NewItemEvent.Set();
count++;
}
}
}
Console.WriteLine("Producer thread: produced {0} items", count);
}
private Queue<int> _queue;
private SyncEvents _syncEvents;
}
public class Consumer
{
public Consumer(Queue<int> q, SyncEvents e)
{
_queue = q;
_syncEvents = e;
}
// Consumer.ThreadRun
public void ThreadRun()
{
int count = 0;
while (WaitHandle.WaitAny(_syncEvents.EventArray) != 1)
{
lock (((ICollection)_queue).SyncRoot)
{
int item = _queue.Dequeue();
}
count++;
}
Console.WriteLine("Consumer Thread: consumed {0} items", count);
}
private Queue<int> _queue;
private SyncEvents _syncEvents;
}
public class ThreadSyncSample
{
private static void ShowQueueContents(Queue<int> q)
{
lock (((ICollection)q).SyncRoot)
{
foreach (int item in q)
{
Console.Write("{0} ", item);
}
}
Console.WriteLine();
}
static void Main()
{
Queue<int> queue = new Queue<int>();
SyncEvents syncEvents = new SyncEvents();
Console.WriteLine("Configuring worker threads...");
Producer producer = new Producer(queue, syncEvents);
Consumer consumer = new Consumer(queue, syncEvents);
Thread producerThread = new Thread(producer.ThreadRun);
Thread consumerThread = new Thread(consumer.ThreadRun);
Console.WriteLine("Launching producer and consumer threads...");
producerThread.Start();
consumerThread.Start();
for (int i=0; i<4; i++)
{
Thread.Sleep(2500);
ShowQueueContents(queue);
}
Console.WriteLine("Signaling threads to terminate...");
syncEvents.ExitThreadEvent.Set();
producerThread.Join();
consumerThread.Join();
}
}
Configuring worker threads...
Launching producer and consumer threads...
22 92 64 70 13 59 9 2 43 52 91 98 50 96 46 22 40 94 24 87
79 54 5 39 21 29 77 77 1 68 69 81 4 75 43 70 87 72 59
0 69 98 54 92 16 84 61 30 45 50 17 86 16 59 20 73 43 21
38 46 84 59 11 87 77 5 53 65 7 16 66 26 79 74 26 37 56 92
Signalling threads to terminate...
Consumer Thread: consumed 1053771 items
Producer thread: produced 1053791 items
Vedere anche
Attività
Esempio di tecnologia della sincronizzazione monitor
Esempio di tecnologia della sincronizzazione di attesa
Concetti
Riferimenti
Sincronizzazione di thread (Guida per programmatori C#)