versione italiana versione italiana
english version english version

Threading in .NET - Thread Pool e Timer (2/3)

Thread Pool e Timer

Capita sovente di voler eseguire delle operazioni “in parallelo” all’attività corrente, senza effettuare chiamate sospensive rispetto all’operazione che si sta eseguendo.

Una soluzione è quella di creare un thread, definendone come punto d’ingresso la funzione da eseguire “in parallelo”. Con .NET la creazione di un thread è un’operazione relativamente semplice, ma il costo operazionale legato alla creazione di un thread non è piccolissimo (risorse, stack, memoria…).

Se l’operazione è relativamente complessa e lunga, può valerne la pena, soprattutto perché si ha pieno controllo sul thread creato. Altre volte, l’operazione da eseguire “in parallelo” è una funzione piuttosto piccola e semplice: in un caso simile, l’overhead dato dalla creazione del thread inizia ad essere significativo.

Esiste poi un altro aspetto, spesso sottovalutato. Se nello stesso momento sono in esecuzione molte operazioni (quindi molti thread), la CPU della macchina viene ripartita, ma una parte significativa del tempo di CPU è assorbito dalle operazioni di switch tra i thread. Se non c’è motivo per cui tutte queste attività debbano avvenire veramente in parallelo, si potrebbe pensare di creare un solo thread di servizio che gestisce un elenco di funzioni da richiamare. È un concetto noto anche come worker thread.

Il concetto di thread pool è qualcosa di simile, ma più complesso e articolato.

Come suggerisce il nome, un thread pool è un insieme di worker thread gestito dal runtime, che assegna delle operazioni richieste dall’utente al primo worker thread libero; in assenza di worker thread liberi, le operazioni sono accodate, per essere eseguite non appena uno dei worker thread si libera.

La classe ThreadPool offre esattamente questo tipo di servizio. Le funzionalità sono esposte solo tramite metodi statici, quindi esiste un solo thread pool per un application domain. Non è possibile controllare né il numero di thread che fanno parte del thread pool (per lo meno non direttamente da codice managed), né la loro priorità o il loro ciclo di vita. Se si ha bisogno di tale controllo, è necessario scriversi una propria libreria basata sulla classe Thread.

Vediamo quindi come possiamo trarre vantaggio dalle funzioni offerte da questa classe. La classe ThreadPool consente di affrontare 2 scenari:

  • Accodare l’esecuzione di una funzione nel thread pool, analogamente a quanto avviene con l’esecuzione di un’operazione asincrona.
  • Associare l’esecuzione di una funzione al verificarsi di un evento

Accodare un’operazione

Una funzione che esegue un’operazione da eseguire in background può essere accodata in un thread pool con la funzione ThreadPool.QueueUserWorkItem.

Concettualmente non è molto diverso dal creare un thread. Per capire la differenza, riprendiamo il primo esempio fatto per i thread:

DIRECTORY: THREAD      FILE: THREAD_01.CS
 
using System;
using System.Threading;
class Demo {
static void WorkBehind() {
Console.WriteLine( "Work behind, thread ID = {0}",
AppDomain.GetCurrentThreadId() );
}
static void Main(string[] args) {
Thread th = new Thread( new ThreadStart( WorkBehind ) );
th.Start();
Console.WriteLine( "Main, thread ID = {0}",
AppDomain.GetCurrentThreadId() );
}
}

Possiamo riscrivere questo programma senza creare un thread apposito per gestire la funzione WorkBehind.

DIRECTORY: THREADPOOL  FILE: THREADPOOL_01.CS
 
using System;
using System.Threading;
class Demo {
static void WorkBehind( object state ) {
Console.WriteLine( "Work behind, thread ID = {0}",
AppDomain.GetCurrentThreadId() );
}
static void Main(string[] args) {
ThreadPool.QueueUserWorkItem( new WaitCallback( WorkBehind ) );
Console.WriteLine( "Main, thread ID = {0}",
AppDomain.GetCurrentThreadId() );
// Wait WorkBehind completion
Thread.Sleep(3000);
}
}

Come per il primo esempio di thread, anche in questo caso l’ordine dell’output non è necessariamente noto. Il risultato può essere:

Main, thread ID = 2644
Work behind, thread ID = 2860

Ma anche:

Work behind, thread ID = 2860
Main, thread ID = 2644

La Sleep finale serve ad evitare che l’applicazione finisca prima di aver eseguito la funzione WorkBehind. Tutti i thread di un thread pool sono dei background thread, quindi l’applicazione termina se non ci sono più foreground thread attivi (in questo caso solo quello del Main ha questa caratteristica).

La classe ThreadPool non fornisce informazioni che consentano al chiamante di avere dei riferimenti per attendere il completamento delle operazioni schedulate. In altre parole, non è disponibile una funzionalità equivalente a Thread.Join.

Il delegate WaitCallback definisce un prototipo che riceve un oggetto di tipo object. Tale oggetto può essere passato attraverso la chiamata a QueueUserWorkItem.

DIRECTORY: THREADPOOL  FILE: THREADPOOL_02.CS
 
using System;
using System.Threading;
class Demo {
static void WorkBehind( object state ) {
Console.WriteLine( "Work behind( {0} ), thread ID = {1}",
state, AppDomain.GetCurrentThreadId() );
}
static void Main(string[] args) {
for (int i = 0; i < 10; i++) {
ThreadPool.QueueUserWorkItem( new WaitCallback( WorkBehind ), i );
}
Console.WriteLine( "Main, thread ID = {0}",
AppDomain.GetCurrentThreadId() );
// Wait WorkBehind completion
Thread.Sleep(3000);
}
}

Con una macchina mono-processore l’output probabilmente sarà:

Main, thread ID = 1920
Work behind( 0 ), thread ID = 4056
Work behind( 1 ), thread ID = 4056
Work behind( 2 ), thread ID = 4056
Work behind( 3 ), thread ID = 4056
Work behind( 4 ), thread ID = 4056
Work behind( 5 ), thread ID = 4056
Work behind( 6 ), thread ID = 4056
Work behind( 7 ), thread ID = 4056
Work behind( 8 ), thread ID = 4056
Work behind( 9 ), thread ID = 4056

È molto interessante però vedere che cosa succede con una macchina bi-processore:

Main, thread ID = 2060
Work behind( 0 ), thread ID = 504
Work behind( 1 ), thread ID = 3428
Work behind( 3 ), thread ID = 3428
Work behind( 2 ), thread ID = 504
Work behind( 5 ), thread ID = 504
Work behind( 4 ), thread ID = 3428
Work behind( 6 ), thread ID = 504
Work behind( 7 ), thread ID = 3428
Work behind( 8 ), thread ID = 504
Work behind( 9 ), thread ID = 3428

Sarebbe estremamente pericoloso assumere che l’ordine in cui le funzioni saranno eseguite (e soprattutto concluse) sia lo stesso con cui sono state accodate al thread pool.

La coda delle funzioni viene smaltita assegnando una funzione al primo thread libero del thread pool. Se il thread è occupato, il thread pool non smaltisce subito la coda, ma prova ad aspettare che l’operazione si concluda. Se ciò non avviene in un tempo relativamente breve, viene creato un nuovo thread (sempre che non sia stato raggiunto il limite di thread per thread pool, normalmente 25 per processore) in cui si esegue la prima funzione in attesa.

Nel caso appena visto, con due processori a disposizione il thread pool crea da subito due thread, uno per processore, quindi si hanno due thread liberi e si possono smaltire “in parallelo” due operazioni. Questo è il motivo per cui le operazioni non sono eseguite nello stesso ordine di richiesta.

Un fenomeno analogo è osservabile anche su una macchina mono-processore, semplicemente aumentando la durata dell’operazione accodata nel thread-pool.

DIRECTORY: THREADPOOL  FILE: THREADPOOL_03.CS
 
using System;
using System.Threading;
class Demo {
static void WorkBehind( object state ) {
Thread.Sleep( new Random().Next( 2000 ) );
Console.WriteLine( "Work behind( {0} ), thread ID = {1}",
state, AppDomain.GetCurrentThreadId() );
}
static void Main(string[] args) {
for (int i = 0; i < 10; i++) {
ThreadPool.QueueUserWorkItem( new WaitCallback( WorkBehind ), i );
}
Console.WriteLine( "Main, thread ID = {0}",
AppDomain.GetCurrentThreadId() );
// Wait WorkBehind completion
Thread.Sleep( 20000 );
}
}

In questo caso bisogna notare che WriteLine è chiamata dopo l’attesa provocata con Sleep, che simula una qualche operazione della funzione WorkBehind.

Main, thread ID = 2964
Work behind( 1 ), thread ID = 1416
Work behind( 0 ), thread ID = 2428
Work behind( 4 ), thread ID = 2560
Work behind( 2 ), thread ID = 1416
Work behind( 3 ), thread ID = 2428
Work behind( 6 ), thread ID = 2452
Work behind( 8 ), thread ID = 2428
Work behind( 7 ), thread ID = 1416
Work behind( 5 ), thread ID = 2560
Work behind( 9 ), thread ID = 1060

Nel mondo reale questo è lo scenario più probabile: non è dato sapere se le operazioni accodate in un thread pool verranno concluse nello stesso ordine con cui sono state richieste. Il metodo migliore per affrontare questo scenario è pensare di creare un thread diverso per ogni funzione accodata al thread pool, dove è possibile che tutte le operazioni siano eseguite in parallelo, senza nessun legame di consequenzialità.

Associare una funzione a un evento

Per un uso ottimale della CPU va evitato accuratamente l’uso di qualsiasi tecnica di polling. Il sistema operativo mette a disposizione una serie di oggetti che consentono di sospendere un thread fino a che non si realizza la condizione attesa. Tipicamente questi oggetti servono a sincronizzare i thread tra loro, senza un significato semantico predefinito.

Un uso comune dei thread pool in Win32 è quello di agganciare delle funzioni a degli handle che segnalano l’avvenuto completamento di un’operazione di I/O. In .NET questo tipo di gestione è presente direttamente nelle funzioni delle classi di System.IO, che offrono delle operazioni asincrone a cui si passa direttamente la funzione callback da chiamare al completamento dell’operazione stessa. Internamente la gestione delle operazioni asincrone avviene sempre con un thread pool.

Nei casi in cui non si utilizzi un’operazione di I/O ma ci si debba sincronizzare rispetto ad un oggetto derivato da WaitHandle, si usa la funzione ThreadPool.RegisterWaitForSingleObject. In questo esempio la funzione Operation viene richiamata quando l’oggetto First viene segnalato (con First.Set()).

DIRECTORY: THREADPOOL  FILE: THREADPOOL_04.CS
 
using System;
using System.Threading;
class Demo {
static void Operation( object state, bool timedOut ) {
Console.WriteLine( "Operation: {0} - thread = {1}",
DateTime.Now.ToLongTimeString(),
AppDomain.GetCurrentThreadId() );
}
static void Main(string[] args) {
AutoResetEvent  First = new AutoResetEvent( false );
ThreadPool.RegisterWaitForSingleObject( First,
new WaitOrTimerCallback( Operation ),
null, Timeout.Infinite, true );
Console.WriteLine( "Start: {0} - thread = {1}",
DateTime.Now.ToLongTimeString(),
AppDomain.GetCurrentThreadId() );
Thread.Sleep( 1000 );
First.Set();
Thread.Sleep( 1000 );
}
}

Le attese ed i tempi visualizzati dimostrano che la chiamata avviene con la sincronizzazione prevista.

Start: 14.16.57 - thread = 2968
Operation: 14.16.58 - thread = 2996

L’operazione può avere un timeout entro cui viene comunque richiamata. Il parametro timedOut passato a Operation segnala questo stato. A causa di questa diversa signature, il delegate usato è WaitOrTimerCallback anziché WaitCallback.

Ovviamente un uso più realistico di questa tecnica avviene in un contesto con molte operazioni asincrone che vanno coordinate tra loro.

DIRECTORY: THREADPOOL  FILE: THREADPOOL_05.CS
 
using System;
using System.Threading;
class Demo {
public AutoResetEvent  First;
public AutoResetEvent  Second;
public AutoResetEvent  Completed;
public Demo() {
First     = new AutoResetEvent( false );
Second    = new AutoResetEvent( false );
Completed = new AutoResetEvent( false );
}
void FirstOperation( object state, bool timedOut ) {
Console.WriteLine( "First operation: {0} - thread = {1}",
DateTime.Now.ToLongTimeString(),
AppDomain.GetCurrentThreadId() );
Second.Set();
}
void SecondOperation( object state, bool timedOut ) {
Console.WriteLine( "Second operation: {0} - thread = {1}",
DateTime.Now.ToLongTimeString(),
AppDomain.GetCurrentThreadId() );
Completed.Set();
}
static void Main(string[] args) {
Demo    demo = new Demo();
ThreadPool.RegisterWaitForSingleObject( demo.Second,
new WaitOrTimerCallback( demo.SecondOperation ),
null, Timeout.Infinite, true );
ThreadPool.RegisterWaitForSingleObject( demo.First,
new WaitOrTimerCallback( demo.FirstOperation ),
null, Timeout.Infinite, true );
Console.WriteLine( "Start: {0} - thread = {1}",
DateTime.Now.ToLongTimeString(),
AppDomain.GetCurrentThreadId() );
Thread.Sleep( 1000 );
demo.First.Set();
demo.Completed.WaitOne();
}
}

In questo caso le due operazioni, FirstOperation e SecondOperation, sono chiamate con un ordine diverso da quello con cui sono state registrate, proprio perché si sincronizzano con gli eventi a cui sono associate e nient’altro.

Start: 14.27.15 - thread = 3432
First operation: 14.27.16 - thread = 1052
Second operation: 14.27.16 - thread = 1052

Notare che il thread in cui viene eseguita la seconda operazione è lo stesso della prima. Questa è una dimostrazione dell’utilità dei thread pool. Visto che la prima operazione è molto breve, è meno costoso attendere che essa finisca per chiamare la seconda sullo stesso thread, piuttosto che creare appositamente un nuovo thread.

Attenzione: quest’aumento della scalabilità complessiva va a discapito del tempo di risposta (latenza) della seconda operazione. Con un thread dedicato che attende la segnalazione di Second si potrebbero sicuramente ottenere una latenza minore, quindi quest’approccio non è consigliabile quando è prioritario ottimizzare i tempi di risposta.

Timer

Uno scenario in cui è utile ricorrere a funzioni eseguite in parallelo è quello in cui si devono eseguire delle operazioni ad intervalli predefiniti, magari piuttosto brevi.

Con la tecnica appena vista si può realizzare qualcosa di simile. Ipotizziamo di voler eseguire una funzione Display ogni secondo, per 5 secondi. Ci serve un thread (o una funzione accodata in un thread pool) che, ad intervalli predefiniti, esegua la funzione che ci interessa.

Qualcosa di simile a:


    static void Controller() {
for (int i = 0; i < 5; i++) {
Thread.Sleep( 1000 );
Display();
}
}

Il problema è che in questo modo non si assicura una chiamata a Display al secondo, perché non si tiene conto del tempo impiegato da Display per completare le sue operazioni.

Immaginiamo di avere una funzione Display come questa:


    static void Display() {
Thread.Sleep( 500 );
}

Come al solito, con Sleep simuliamo un’operazione di una certa durata. La chiamata a Display avverrebbe così ogni secondo e mezzo. Se vogliamo rispettare la tempistica prevista, dobbiamo diminuire l’attesa nel loop di chiamata della funzione Controller. Non è detto che ciò sia possibile, perché Display potrebbe avere un tempo d’esecuzione variabile, e calcolare i tempi d’esecuzione complica ulteriormente il codice della funzione Controller.

Con le tecniche viste finora, possiamo disaccoppiare la chiamata di Display dal thread di Controller. Vediamo questa funzione in un esempio completo, in cui anche la funzione Controller è eseguita in un thread pool, per non “bloccare” l’esecuzione del thread principale.

DIRECTORY: TIMER       FILE: TIMER_01.CS
 
using System;
using System.Threading;
class Demo {
static void Display( object state, bool timedOut ) {
Console.WriteLine( "Ore {0}", DateTime.Now.ToLongTimeString() );
Thread.Sleep(500);
Console.WriteLine( "exit" );
}
static void Controller( object state ) {
AutoResetEvent  impulse = new AutoResetEvent( false );
ThreadPool.RegisterWaitForSingleObject(
impulse, new WaitOrTimerCallback( Display ),
null, -1, false );
for (int i = 0; i < 5; i++) {
Thread.Sleep( 1000 );
impulse.Set();
}
}
static void Main(string[] args) {
ThreadPool.QueueUserWorkItem( new WaitCallback( Controller ) );
// Wait completion
Thread.Sleep(6000);
}
}

In questo modo la durata di Display non influenza in alcun modo l’esecuzione delle chiamate successive, che avverrà ogni secondo.

Ore 15.50.49
exit
Ore 15.50.50
exit
Ore 15.50.51
exit
Ore 15.50.52
exit
Ore 15.50.53
exit

L’unica differenza significativa rispetto al caso precedente è che la funzione Display è stata registrata una volta sola, quindi il parametro state che riceve sarà lo stesso per tutte le chiamate successive, a differenza del caso precedente in cui per ogni ciclo la chiamata avrebbe potuto passare parametri diversi.

 

Lo scenario appena descritto è piuttosto comune: per affrontarlo esistono due classi Timer che semplificano il codice. Una appartiene al namespace System.Windows.Forms, non è oggetto di questo articolo e va usata quando l’operazione eseguita dal timer deve interagire con l’interfaccia utente di Windows Forms, per evitare la sincronizzazione delle chiamate provenienti da thread diversi da quello dell’interfaccia utente che è necessaria usando questa libreria. L’altra appartiene al namespace System.Threading e usa internamente il meccanismo di thread pool.

Con tale classe possiamo scrivere questo codice:

DIRECTORY: TIMER       FILE: TIMER_02.CS
 
using System;
using System.Threading;
class Demo {
static void Display( object state ) {
Console.WriteLine( "Ore {0}", DateTime.Now.ToLongTimeString() );
Thread.Sleep(500);
Console.WriteLine( "exit" );
}
static void Main(string[] args) {
Timer   timer = new Timer( new TimerCallback( Display ),null, 0, 1000 );
// Wait completion
Thread.Sleep(6000);
}
}

Il risultato, che non riportiamo, è identico all’esempio precedente, quello che cambia è il codice con cui l’otteniamo.

La funzione Controller non è più necessaria, la classe Timer offre lo stesso servizio. I parametri del costruttore consentono di definire il parametro da passare alla funzione chiamata periodicamente, il ritardo sulla prima chiamata e l’intervallo tra due chiamate. Usando Timeout.Infinite per quest’ultimo parametro si può schedulate l’esecuzione di un’operazione a distanza di un certo numero di millisecondi.