Capita spesso di dover eseguire una certa elaborazione al verificarsi di un evento. Ovviamente, non faccio riferimento agli eventi scatenati sulla UI (es. click di un bottone,..) ai quali la gestione è demandata ai relativi event handlers, ma mi riferisco ad eventi intrinseci dell’applicazione che magari servono per sincronizzare uno o più thread fra loro (es. sbloccare un thread nel momento in cui un altro thread ha terminato una computazione ed ha reso disponibili dei dati).
La soluzione tipica è quella di creare un WaitHandle (concretamente un AutoResetEvent oManualResetEvent in base alle esigenze) ed un thread, dopodichè bloccare quest’ultimo mediante una WaitOne() sull’evento stesso. In questo modo, il thread va in wait e si sbloccherà nel momento in cui “qualcuno” (un altro thread o il main thread) eseguirà unaSet() sull’evento. Questa tecnica, prevede comunque la creazione di un thread custom da parte dello sviluppatore.
Ebbene, anche in una situazione di questo tipo, ci viene fornito supporto dal thread pool del CLR. Infatti, utilizzando il metodo statico RegisterWaitForSingleObject() della classeThreadPool, è possibile demandare al CLR il monitoring di un evento e far eseguire una callback nel caso in cui quest’ultimo diventi signaled o nel caso in cui scatti un timeout.
Il metodo RegisterWaitForSingleObject prevede i seguenti parametri :
-
un WaitHandle, ossia l’evento da monitorare;
-
la callback da eseguire nel momento in cui l’evento viene segnalato o scatta il timeout. La callaback è definita attraverso il delegate WaitOrTimerCallback;
-
un oggetto di stato da passare alla callback (utile per trasferire dati a quest’ultima);
-
un timeout in millisecondi allo scattare del quale la callback viene comunque invocata nonostante l’evento non sia stato segnalato;
-
una flag che indica se il monitoring dell’evento deve essere ripetuto nel tempo oppure se va eseguito una sola volta (nel cui caso al signaling dell’evento o alla scattare del timeout, l’evento non verrà più monitorato);
Infine, esso ritorna un RegisteredWaitHandle sul quale va eseguito il metodo diUnregister() nel momento in cui non è più necessario, in modo da liberare le risorse allocate per l’evento. E’ da osservare che il delegate WaitOrTimerCallback prevede un primo parametro che rappresenta lo stato eventualmente passato alla callback ed un secondo parametro booleano che indica se la callback è stata chiamata per timeout o meno.
Consideriamo la seguente applicazione console :
class Program
{
// numero worker threads e completion port threads
private static int workerThreads, completionPortThreads;
private const int SCHEDULING_DELAY = 1000;
private const int NR_WAIT_CALLBACK = 2;
private const long NR_DATA = 100000000;
private const int WAIT_TIMEOUT = 5000;
private const int SET_DELAY = 1000;
private static AutoResetEvent[] waitEvent; // wait handles da monitorare
private static AutoResetEvent[] syncEvent; // wait handles per la sincronizzazione con il main thread
private static RegisteredWaitHandle[] regWaitHandle; // handle relativi alle risorse allocate dal manager
// del thread pool per il monitoring
private static Stopwatch sw;
static void Main(string[] args)
{
waitEvent = new AutoResetEvent[NR_WAIT_CALLBACK];
syncEvent = new AutoResetEvent[NR_WAIT_CALLBACK];
regWaitHandle = new RegisteredWaitHandle[NR_WAIT_CALLBACK];
sw = new Stopwatch();
WriteAvailableThreads();
sw.Start();
Console.WriteLine("Generating wait event...");
for (int idx = 0; idx < NR_WAIT_CALLBACK; idx++)
{
waitEvent[idx] = new AutoResetEvent(false);
syncEvent[idx] = new AutoResetEvent(false);
regWaitHandle[idx] = ThreadPool.RegisterWaitForSingleObject(waitEvent[idx], WaitMethod, idx, WAIT_TIMEOUT, true);
Thread.Sleep(SCHEDULING_DELAY);
}
Console.WriteLine("Waiting for all wait methods...");
WaitHandle.WaitAll(syncEvent);
sw.Stop();
Console.WriteLine("All wait methods finished, elapsed time = {0} ms", sw.ElapsedMilliseconds);
for (int j = 0; j < NR_WAIT_CALLBACK; j++)
{
regWaitHandle[j].Unregister(waitEvent[j]);
}
Console.ReadLine();
}
private static void WriteAvailableThreads()
{
ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads);
Console.WriteLine("workerthreads = {0}, completionPortThreads = {1}", workerThreads, completionPortThreads);
}
private static void WaitMethod(object state, bool timeout)
{
int waitMethodIdx = (int)state;
Console.WriteLine("Start WaitMethod{0} with timeout={1} at {2} ms with ThreadId {3}", waitMethodIdx + 1, timeout, sw.ElapsedMilliseconds, Thread.CurrentThread.ManagedThreadId);
WriteAvailableThreads();
int sum = 0;
for (int i = 0; i < NR_DATA; i++)
{
sum += i;
}
Console.WriteLine("End WaitMethod{0} at {1} ms with ThreadId {2}", waitMethodIdx + 1, sw.ElapsedMilliseconds, Thread.CurrentThread.ManagedThreadId);
syncEvent[waitMethodIdx].Set();
}
}
In cui :
-
waitEvent è l’array con i wait handle da monitorare e sui quali saremo in attesa;
-
syncEvent è l’array con i wait handle per la sincronizzazione tra il main thread ed i thread relativi alle callback eseguite in seguito allo scattare degli eventi o del timeout;
-
regWaitHandle è l’array con i RegisteredWaitHandle usati dal manager del thread pool per gestire il monitoring;
L’applicativo non fa nient’altro che registrare NR_WAIT_CALLBACK in attesa sugli eventi dell’array waitEvent con un tempo di schedulazione di SCHEDULING_DELAY millisecondi. Inoltre, è definito un timeout pari a WAIT_TIMEOUT millisecondi. La prima versione del codice applicativo, prevede che le callback si sblocchino tutte per timeout. Infine, NR_DATA servirà per simulare un’elaborazione nelle callback.
Proviamo ad eseguirla con i seguenti valori :
-
SCHEDULING_DELAY = 100;
-
NR_WAIT_CALLBACK = 2;
-
NR_DATA = 100000000;
-
WAIT_TIMEOUT = 5000;
Otteniamo il seguente output :
SCHEDULING_DELAY = 100, NR_WAIT_CALLBACK = 2, WAIT_TIMEOUT = 5000
In questo caso, la registrazione di wait sull’handle avviene rapidamente (100 ms di delay) e si osserva che, allo scattare del timeout (5 sec) ovviamente le due callback dovranno essere chiamate “quasi” in contemporanea, considerando anche il tempo abbastanza lungo di elaborazione per ciascuna di esse. Per questo motivo, vengono allocati due completion port thread per eseguire tali callback.
Proviamo ad aumentare il delay tra una richiesta di registrazione e l’altra. Otteniamo …
SCHEDULING_DELAY = 1000, NR_WAIT_CALLBACK = 2, WAIT_TIMEOUT = 5000
In questo caso, la prima callback verrà invocata un secondo prima della seconda callback e considerato il fatto che l’elaborazione termina entro tale secondo, il manager del thread pool riesce a riciclare il medesimo completion port thread per gestirle entrambe. Se proviamo, però, ad aumentare il tempo di elaborazione (portando NR_DATA a 1000000000), la prima callback non sarà ancora terminata quando scatterà il timeout per la seconda, per cui il manager del thread pool sarà costretto ad allocare un nuovo thread.
SCHEDULING_DELAY = 1000, NR_WAIT_CALLBACK = 2, WAIT_TIMEOUT = 5000, NR_DATA = 1000000000
Aggiungiamo ora la condizione di sblocco settando gli eventi. Per fare questo, inseriamo le seguenti righe di codice subito dopo il ciclo di registrazione delle callback :
for (int k = 0; k < NR_WAIT_CALLBACK; k++)
{
waitEvent[k].Set();
Thread.Sleep(SET_DELAY);
}
Tra un’operazione di signaling di un evento e la successiva, lasciamo trascorrere un certo tempo per simulare uno sblocco differito. Ovviamente, modulando questo tempo, verrà riciclato o meno lo stesso thread del pool per eseguire la callback :
-
con SET_DELAY piccolo, i signaling sono così ravvicinati e magari l’elaborazione nella callback abbastanza lunga da costringere il CLR ad allocare thread separati nel pool;
-
con SET_DELAY grande, considerando il medesimo tempo di elaborazione nella callback, passa comunque più tempo tra un signaling ed il successivo per cui il CLR ha il tempo di riciclare il medesimo thread;
Riporto di seguito due possibili situazioni
SET_DELAY=100
SET_DELAY=1000