10. I thread di esecuzione
10.1. La classe Thread
Quando si avvia un’applicazione, questa viene eseguita in un flusso di esecuzione denominato thread. La classe .NET che modella un thread è la classe System.Threading.Thread e presenta la seguente definizione:
Costruttori
![]() |
Negli esempi che seguiranno utilizzeremo solo i costruttori [1,3]. Il costruttore [1] accetta come parametro un metodo con la firma [2], c.a.d, avente un parametro di tipo object e che non restituisce alcun risultato. Il costruttore [3] accetta come parametro un metodo con la firma [4], c.a.d, che non ha parametri e non restituisce alcun risultato.
Proprietà
Alcune proprietà utili:
- Thread CurrentThread: proprietà statica che fornisce un riferimento al thread in cui si trova il codice che ha richiesto questa proprietà
- string Name: il nome del thread
- bool IsAlive: indica se il thread è in esecuzione o meno.
Metodi
I metodi più utilizzati sono i seguenti:
- Start(), Start(object obj): avvia l'esecuzione asincrona del thread, eventualmente passando informazioni tramite un oggetto di tipo object.
- Abort(), Abort(object obj): per terminare forzatamente un thread
- Join(): il thread T1 che esegue T2.Join rimane bloccato fino al completamento del thread T2. Esistono varianti per terminare l'attesa dopo un determinato periodo di tempo.
- Sleep(int n): metodo statico - il thread che esegue il metodo viene sospeso per n millisecondi. In questo modo perde il controllo del processore, che viene assegnato a un altro thread.
Vediamo una prima applicazione che evidenzia l’esistenza di un thread di esecuzione principale, quello in cui viene eseguita la funzione Main di una classe:
using System;
using System.Threading;
namespace Chap8 {
class Program {
static void Main(string[] args) {
// inizializzazione thread corrente
Thread main = Thread.CurrentThread;
// visualizzazione
Console.WriteLine("Thread courant : {0}", main.Name);
// si modifica il nome
main.Name = "main";
// verifica
Console.WriteLine("Thread courant : {0}", main.Name);
// ciclo infinito
while (true) {
// visualizzazione
Console.WriteLine("{0} : {1:hh:mm:ss}", main.Name, DateTime.Now);
// arresto temporaneo
Thread.Sleep(1000);
}//while
}
}
}
- riga 8: si recupera un riferimento al thread in cui viene eseguito il metodo [main]
- righe 10-14: si visualizza e si modifica il suo nome
- righe 17-22: un ciclo che esegue una visualizzazione ogni secondo
- riga 21: il thread in cui viene eseguito il metodo [main] verrà sospeso per 1 secondo
I risultati visualizzati sullo schermo sono i seguenti:
- riga 1: il thread corrente non aveva un nome
- riga 2: ora ne ha uno
- righe 3-7: il messaggio visualizzato ogni secondo
- riga 8: il programma viene interrotto con Ctrl-C.
10.2. Creazione di thread di esecuzione
È possibile avere applicazioni in cui parti di codice vengono eseguite in modo "simultaneo" in diversi thread di esecuzione. Quando si dice che i thread vengono eseguiti simultaneamente, spesso si commette un abuso di linguaggio. Se la macchina ha un solo processore, come spesso accade ancora oggi, i thread si dividono questo processore: ne dispongono, a turno, per un breve istante (alcuni millisecondi). È questo che dà l’illusione del parallelismo di esecuzione. Il tempo assegnato a un thread dipende da vari fattori, tra cui la sua priorità, che ha un valore predefinito ma può essere impostata anche tramite programmazione. Quando un thread ha a disposizione il processore, lo utilizza normalmente per tutto il tempo che gli è stato assegnato. Tuttavia, può liberarlo prima del termine:
- mettendosi in attesa di un evento (Wait, Join)
- mettendosi in sospensione per un periodo di tempo determinato (Sleep)
- Un thread T viene innanzitutto creato da uno dei costruttori presentati in precedenza, ad esempio:
dove Start è un metodo con una delle due seguenti firme:
La creazione di un thread non ne avvia l'esecuzione.
- L'esecuzione del thread T viene avviata da T.Start(): il metodo Start passato al costruttore di T verrà quindi eseguito dal thread T. Il programma che esegue l'istruzione T.Start() non attende il completamento dell'attività T: passa immediatamente all'istruzione successiva. Si hanno quindi due attività che vengono eseguite in parallelo. Spesso devono potersi comunicare tra loro per sapere a che punto è il lavoro comune da svolgere. Questo è il problema della sincronizzazione dei thread.
- Una volta avviato, il thread T viene eseguito in modo autonomo. Si interromperà quando il metodo Start che sta eseguendo avrà terminato il proprio lavoro.
- È possibile forzare la chiusura del thread T:
- T.Abort() richiede al thread T di terminare.
- È anche possibile attendere la fine della sua esecuzione tramite T.Join(). Si tratta di un'istruzione bloccante: il programma che la esegue rimane bloccato finché il thread T non ha terminato il proprio lavoro. È un metodo di sincronizzazione.
Esaminiamo il seguente programma:
using System;
using System.Threading;
namespace Chap8 {
class Program {
public static void Main() {
// inizializzazione del thread corrente
Thread main = Thread.CurrentThread;
// assegnazione di un nome al thread
main.Name = "Main";
// creazione di thread di esecuzione
Thread[] tâches = new Thread[5];
for (int i = 0; i < tâches.Length; i++) {
// si crea il thread i
tâches[i] = new Thread(Affiche);
// si assegna il nome al thread
tâches[i].Name = i.ToString();
// si avvia l'esecuzione del thread i
tâches[i].Start();
}
// fine della funzione
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}",main.Name,DateTime.Now);
}
public static void Affiche() {
// visualizzazione dell'inizio dell'esecuzione
Console.WriteLine("Début d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}",Thread.CurrentThread.Name,DateTime.Now);
// sospensione per 1 s
Thread.Sleep(1000);
// visualizzazione della fine dell'esecuzione
Console.WriteLine("Fin d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}", Thread.CurrentThread.Name, DateTime.Now);
}
}
}
- righe 8-10: si assegna un nome al thread che esegue il metodo [Main]
- righe 13-21: si creano 5 thread e li si esegue. I riferimenti ai thread vengono memorizzati in un array per poterli recuperare in seguito. Ogni thread esegue il metodo Affiche delle righe 27-35.
- riga 20: viene avviato il thread n. i. Questa operazione è non bloccante. Il thread n. i verrà eseguito in parallelo al thread del metodo [Main] che lo ha avviato.
- riga 24: il thread che esegue il metodo [Main] termina.
- righe 27-35: il metodo [Affiche] esegue alcune operazioni di visualizzazione. Visualizza il nome del thread che lo sta eseguendo, nonché l’ora di inizio e di fine dell’esecuzione.
- riga 31: ogni thread che esegue il metodo [Affiche] si interromperà per 1 secondo. Il processore verrà quindi assegnato a un altro thread in attesa di processore. Al termine del secondo di sospensione, il thread sospeso diventerà candidato all’uso del processore. Lo otterrà quando sarà il suo turno. Ciò dipende da vari fattori, tra cui la priorità degli altri thread in attesa di processore.
I risultati sono i seguenti:
Questi risultati sono molto istruttivi:
- si nota innanzitutto che l’avvio dell’esecuzione di un thread non è bloccante. Il metodo Main ha avviato l’esecuzione di 5 thread in parallelo e ha terminato la propria esecuzione prima di loro. L’operazione
avvia l'esecuzione del thread tâches[i], ma una volta fatto ciò, l'esecuzione prosegue immediatamente con l'istruzione successiva senza attendere il termine dell'esecuzione del thread.
- Tutti i thread creati devono eseguire il metodo Affiche. L'ordine di esecuzione è imprevedibile. Anche se nell'esempio l'ordine di esecuzione sembra seguire l'ordine delle richieste di esecuzione, non è possibile trarne conclusioni generali. Il sistema operativo dispone in questo caso di 6 thread e un processore. Distribuirà il processore a questi 6 thread secondo regole proprie.
- Nei risultati si osserva una conseguenza del metodo Sleep. Nell’esempio, è il thread 0 che esegue per primo il metodo Affiche. Viene visualizzato il messaggio di inizio esecuzione, dopodiché il thread esegue il metodo Sleep, che lo sospende per 1 secondo. A quel punto perde il processore, che diventa così disponibile per un altro thread. L’esempio mostra che sarà il thread 1 a ottenerlo. Il thread 1 seguirà lo stesso percorso, così come gli altri thread. Al termine del secondo di sospensione del thread 0, la sua esecuzione potrà riprendere. Il sistema gli assegna il processore e il thread 0 può completare l’esecuzione del metodo Affiche.
Modifichiamo il nostro programma per terminare il metodo Main con le istruzioni:
// fine del thread
Console.WriteLine("Fin du thread " + main.Name);
// si arrestano tutti i thread
Environment.Exit(0);
L'esecuzione del nuovo programma produce i seguenti risultati:
- righe 1-5: i thread creati dalla funzione Main iniziano la loro esecuzione e vengono interrotti per 1 secondo
- riga 6: il thread [Main] riprende il controllo del processore ed esegue l'istruzione:
Questa istruzione arresta tutti i thread dell'applicazione e non solo il thread Main.
Se il metodo Main desidera attendere il completamento dell’esecuzione dei thread che ha creato, può utilizzare il metodo Join della classe Thread:
public static void Main() {
...
// si attendono tutti i thread
for (int i = 0; i < tâches.Length; i++) {
// in attesa della fine dell'esecuzione del thread i
tâches[i].Join();
}
// fine del ciclo main
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
- riga 6: il thread [Main] attende ciascuno dei thread. Viene prima bloccato in attesa del thread n. 1, poi del thread n. 2, ecc... Alla fine, quando esce dal ciclo delle righe 2-5, significa che i 5 thread che ha avviato sono terminati.
Si ottengono quindi i seguenti risultati:
- riga 11: il thread [Main] si è concluso dopo i thread che aveva avviato.
10.3. Utilità dei thread
Ora che abbiamo evidenziato l’esistenza di un thread predefinito, quello che esegue il metodo Main, e che sappiamo come crearne altri, soffermiamoci sull’utilità dei thread per noi e sui motivi per cui li presentiamo qui. Esiste una tipologia di applicazioni che si presta particolarmente bene all’uso dei thread: si tratta delle applicazioni client-server su Internet. Le presenteremo nel capitolo seguente. In un’applicazione client-server su Internet, un server situato su una macchina S1 risponde alle richieste dei client situati su macchine remote C1, C2, ..., Cn.
![]() |
Utilizziamo quotidianamente applicazioni Internet che seguono questo schema: servizi Web, posta elettronica, consultazione di forum, trasferimento di file... Nello schema sopra riportato, il server S1 deve servire i client Ci contemporaneamente. Se prendiamo l’esempio di un server FTP (File Transfer Protocol) che fornisce file ai propri clienti, sappiamo che un trasferimento di file può talvolta richiedere diversi minuti. È ovviamente fuori discussione che un singolo cliente monopolizzi il server per un periodo così lungo. Di norma, il server crea un numero di thread di esecuzione pari al numero di clienti. Ciascun thread è quindi incaricato di gestire un cliente specifico. Poiché il processore viene condiviso ciclicamente tra tutti i thread attivi della macchina, il server dedica un po’ di tempo a ciascun cliente, garantendo così la simultaneità del servizio.
![]() |
In pratica, il server utilizza un pool di thread con un numero limitato di thread, ad esempio 50. Il 51° cliente viene quindi invitato ad attendere.
10.4. Scambio di informazioni tra thread
Negli esempi precedenti, un thread veniva inizializzato nel modo seguente:
dove Run era un metodo con la seguente firma:
È anche possibile utilizzare la seguente firma:
Ciò consente di trasmettere informazioni al thread avviato. Pertanto
avvierà il thread t, il quale a sua volta eseguirà il metodo Run, ad esso associato per impostazione predefinita, passando il parametro effettivo obj1. Ecco un esempio:
using System;
using System.Threading;
namespace Chap8 {
class Program4 {
public static void Main() {
// inizializzazione del thread corrente
Thread main = Thread.CurrentThread;
// si assegna un nome al thread
main.Name = "Main";
// Creazione dei thread di esecuzione
Thread[] tâches = new Thread[5];
Data[] data = new Data[5];
for (int i = 0; i < tâches.Length; i++) {
// si crea il thread i
tâches[i] = new Thread(Sleep);
// si imposta il nome del thread
tâches[i].Name = i.ToString();
// si avvia l'esecuzione del thread i
tâches[i].Start(data[i] = new Data { Début = DateTime.Now, Durée = i+1 });
}
// si attende il completamento di tutti i thread
for (int i = 0; i < tâches.Length; i++) {
// si attende la fine dell'esecuzione del thread i
tâches[i].Join();
// visualizzazione del risultato
Console.WriteLine("Thread {0} terminé : début {1:hh:mm:ss}, durée programmée {2} s, fin {3:hh:mm:ss}, durée effective {4}",
tâches[i].Name,data[i].Début,data[i].Durée,data[i].Fin,(data[i].Fin-data[i].Début));
}
// fine della routine
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
public static void Sleep(object infos) {
// si recupera il parametro
Data data = (Data)infos;
// sospensione per Durata secondi
Thread.Sleep(data.Durée*1000);
// fine esecuzione
data.Fin = DateTime.Now;
}
}
internal class Data {
// informazioni varie
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
- righe 45-50: le informazioni di tipo [Data] passate ai thread:
- Début: ora di inizio dell'esecuzione del thread - fissata dal thread lanciatore
- Durée: durata in secondi dello Sleep eseguito dal thread lanciato - impostata dal thread lanciatore
- Fin: ora di inizio dell'esecuzione del thread - impostata dal thread lanciatore
- righe 35-43: il metodo Sleep eseguito dai thread ha la firma void Sleep(object obj). Il parametro effettivo obj sarà del tipo [Data] definito alla riga 45.
- righe 15-22: creazione di 5 thread
- riga 17: ogni thread è associato al metodo Sleep della riga 35
- riga 21: un oggetto di tipo [Data] viene passato al metodo Start che avvia il thread. In questo oggetto sono stati registrati l’ora di inizio dell’esecuzione del thread e la durata in secondi durante la quale deve rimanere inattivo. Questo oggetto viene memorizzato nell’array della riga 14.
- righe 24-30: il thread [Main] attende il completamento di tutti i thread che ha avviato.
- righe 28-29: il thread [Main] recupera l’oggetto data[i] dal thread n. i e ne visualizza il contenuto.
- righe 35-42: il metodo Sleep eseguito dai thread
- riga 37: viene recuperato il parametro di tipo [Data]
- riga 39: il campo Durée del parametro viene utilizzato per impostare la durata di Sleep
- riga 41: il campo Fin del parametro viene inizializzato
I risultati dell'esecuzione sono i seguenti:
Questo esempio mostra che due thread possono scambiarsi informazioni:
- il thread lanciatore può controllare l'esecuzione del thread lanciato fornendogli informazioni
- il thread lanciato può restituire i risultati al thread lanciatore.
Affinché il thread lanciato sappia quando sono disponibili i risultati che sta aspettando, deve essere avvisato della fine del thread lanciato. In questo caso, ha atteso che terminasse utilizzando il metodo Join. Esistono altri modi per ottenere lo stesso risultato. Li vedremo in seguito.
10.5. Accesso concorrente alle risorse condivise
10.5.1. Accessi concorrenti non sincronizzati
Nel paragrafo dedicato allo scambio di informazioni tra thread, le informazioni venivano scambiate solo tra due thread e in momenti ben precisi. Si trattava di un classico passaggio di parametri. Esistono altri casi in cui un’informazione è condivisa da più thread che potrebbero volerla leggere o aggiornare contemporaneamente. Si pone quindi il problema dell’integrità di tale informazione. Supponiamo che l’informazione condivisa sia una struttura S contenente varie informazioni I1, I2, ... In.
- un thread T1 inizia ad aggiornare la struttura S: modifica il campo I1 e viene interrotto prima di aver completato l’aggiornamento della struttura S
- un thread T2, che ottiene il controllo del processore, legge quindi la struttura S per prendere delle decisioni. Legge una struttura in uno stato instabile: alcuni campi sono aggiornati, altri no.
Questa situazione viene definita «accesso a una risorsa condivisa», in questo caso la struttura S, ed è spesso piuttosto complessa da gestire. Prendiamo il seguente esempio per illustrare i problemi che possono sorgere:
- un’applicazione genererà n thread, dove n è passato come parametro
- la risorsa condivisa è un contatore che dovrà essere incrementato da ciascun thread generato
- al termine dell’applicazione, viene visualizzato il valore del contatore. Ci si dovrebbe quindi aspettare di trovare n.
Il programma è il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program {
// variabili di classe
static int cptrThreads = 0; // contatore dei thread
//main
public static void Main(string[] args) {
// istruzioni per l'uso
const string syntaxe = "pg nbThreads";
const int nbMaxThreads = 100;
// verifica del numero di argomenti
if (args.Length != 1) {
// errore
Console.WriteLine(syntaxe);
// arresto
Environment.Exit(1);
}
// verifica della qualità dell'argomento
int nbThreads = 0;
bool erreur = false;
try {
nbThreads = int.Parse(args[0]);
if (nbThreads < 1 || nbThreads > nbMaxThreads)
erreur = true;
} catch {
// errore
erreur = true;
}
// errore?
if (erreur) {
// errore
Console.Error.WriteLine("Nombre de threads incorrect (entre 1 et 100)");
// fine
Environment.Exit(2);
}
// creazione e generazione dei thread
Thread[] threads = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creazione
threads[i] = new Thread(Incrémente);
// denominazione
threads[i].Name = "" + i;
// avvio
threads[i].Start();
}//for
// attesa del completamento dei thread
for (int i = 0; i < nbThreads; i++) {
threads[i].Join();
}
// visualizzazione contatore
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// aumenta il contatore dei thread
// lettura contatore
int valeur = cptrThreads;
// monitoraggio
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
// attesa
Thread.Sleep(1000);
// incremento del contatore
cptrThreads = valeur + 1;
// monitoraggio
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
}
}
}
Non ci soffermeremo sulla parte relativa alla generazione dei thread, già studiata in precedenza. Concentriamoci piuttosto sul metodo Incrémente, alla riga 59, utilizzato da ciascun thread per incrementare il contatore statico cptrThreads alla riga 8.
- riga 62: il contatore viene letto
- riga 66: il thread si ferma per 1 s. Perde quindi il controllo del processore
- riga 68: il contatore viene incrementato
La fase 2 serve solo a costringere il thread a cedere il processore. Questo verrà assegnato a un altro thread. In pratica, nulla garantisce che un thread non venga interrotto tra il momento in cui legge il contatore e quello in cui lo incrementa. Anche se si scrive cptrThreads++, dando così l’illusione di un’unica istruzione, esiste il rischio di perdere il controllo del processore tra il momento in cui si legge il valore del contatore e quello in cui si scrive il suo valore incrementato di 1. Infatti, l’operazione di alto livello cptrThreads++ sarà scomposta in diverse istruzioni elementari a livello del processore. La fase 2 di sospensione di un secondo serve quindi solo a sistematizzare questo rischio.
I risultati ottenuti con 5 thread sono i seguenti:
Leggendo questi risultati, si capisce bene cosa succede:
- riga 1: un primo thread legge il contatore. Trova 0. Si ferma per 1 s, perdendo così il controllo del processore
- riga 2: un secondo thread prende quindi il controllo del processore e legge a sua volta il valore del contatore. È ancora 0 poiché il thread precedente non lo ha ancora incrementato. Anche lui si ferma per 1 s e a sua volta perde il controllo del processore.
- righe 1-5: in 1 s, i 5 thread hanno il tempo di passare tutti e di leggere tutti il valore 0.
- righe 6-10: quando si riattiveranno uno dopo l’altro, incrementeranno il valore 0 che hanno letto e scriveranno il valore 1 nel contatore, come conferma il programma principale (Main) alla riga 11.
Da dove deriva il problema? Il secondo thread ha letto un valore errato poiché il primo era stato interrotto prima di aver completato il proprio compito, ovvero aggiornare il contatore nella finestra. Questo ci porta al concetto di risorsa critica e di sezione critica di un programma:
- una risorsa critica è una risorsa che può essere detenuta da un solo thread alla volta. In questo caso la risorsa critica è il contatore.
- una sezione critica di un programma è una sequenza di istruzioni nel flusso di esecuzione di un thread durante la quale esso accede a una risorsa critica. È necessario garantire che, durante questa sezione critica, il thread sia l’unico ad avere accesso alla risorsa.
Nel nostro esempio, la sezione critica è il codice compreso tra la lettura del contatore e la scrittura del suo nuovo valore:
// lettura contatore
int valeur = cptrThreads;
// in attesa
Thread.Sleep(1000);
// incremento contatore
cptrThreads = valeur + 1;
Per eseguire questo codice, è necessario garantire che un thread sia l’unico a farlo. Può essere interrotto, ma durante tale interruzione nessun altro thread deve poter eseguire lo stesso codice. La piattaforma .NET offre diversi strumenti per garantire l’accesso unitario alle sezioni critiche del codice. Ne vedremo ora alcuni.
10.5.2. La clausola lock
La clausola lock consente di delimitare una sezione critica nel modo seguente:
obj deve essere un riferimento a un oggetto visibile a tutti i thread che eseguono la sezione critica. La clausola lock garantisce che solo un thread alla volta esegua la sezione critica. L’esempio precedente viene riscritto come segue:
using System;
using System.Threading;
namespace Chap8 {
class Program2 {
// variabili di classe
static int cptrThreads = 0; // contatore dei thread
static object synchro = new object(); // oggetto di sincronizzazione
//main
public static void Main(string[] args) {
...
// attesa della fine dei thread
Thread.CurrentThread.Name = "Main";
for (int i = nbThreads - 1; i >= 0; i--) {
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} attend la fin du thread {2}", DateTime.Now, Thread.CurrentThread.Name, threads[i].Name);
threads[i].Join();
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a été prévenu de la fin du thread {2}", DateTime.Now, Thread.CurrentThread.Name, threads[i].Name);
}
// visualizzazione contatore
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// aumenta il contatore dei thread
// viene richiesto un accesso esclusivo al contatore
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} attend l'autorisation d'entrer dans la section critique", DateTime.Now, Thread.CurrentThread.Name);
lock (synchro) {
// lettura del contatore
int valeur = cptrThreads;
// monitoraggio
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
// attesa
Thread.Sleep(1000);
// incremento del contatore
cptrThreads = valeur + 1;
// monitoraggio
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
}
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a quitté la section critique", DateTime.Now, Thread.CurrentThread.Name);
}
}
}
- riga 9: synchro è l’oggetto che consentirà la sincronizzazione di tutti i thread.
- righe 16-23: il metodo [Main] attende i thread nell’ordine inverso rispetto a quello della loro creazione.
- righe 29-40: la sezione critica del metodo Incrémente è stata racchiusa dalla clausola lock.
I risultati ottenuti con 3 thread sono i seguenti:
- il thread 0 entra per primo nella sezione critica: righe 1, 2, 6, 8
- gli altri due thread rimarranno bloccati finché il thread 0 non sarà uscito dalla sezione critica: righe 3 e 4
- il thread 1 passa quindi: righe 7, 9, 10
- successivamente passa il thread 2: righe 11, 12, 13
- riga 14: il thread Main, che stava aspettando la fine del thread 2, viene avvisato
- riga 15: il thread Main ora attende la fine del thread 1. Quest’ultimo è già terminato. Il thread Main ne viene immediatamente informato, riga 16.
- righe 17-18: lo stesso processo si ripete con il thread 0
- riga 19: il numero di thread è corretto
10.5.3. La classe Mutex
Anche la classe System.Threading.Mutex consente di delimitare sezioni critiche. Si differenzia dalla clausola lock in termini di visibilità:
- la clausola lock consente di sincronizzare i thread di una stessa applicazione
- la classe Mutex consente di sincronizzare thread di applicazioni diverse.
Utilizzeremo il costruttore e i seguenti metodi:
crea un Mutex M | |
Il thread T1 che esegue l'operazione M.WaitOne() richiede il possesso dell'oggetto di sincronizzazione M. Se l'oggetto Mutex M non è detenuto da alcun thread (come avviene inizialmente), viene «assegnato» al thread T1 che lo ha richiesto. Se poco dopo un thread T2 esegue la stessa operazione, verrà bloccato. Infatti, un Mutex può appartenere a un solo thread. Verrà sbloccato quando il thread T1 libererà il Mutex M che detiene. Diversi thread possono quindi rimanere bloccati in attesa del Mutex M. | |
Il thread T1 che esegue l’operazione M.ReleaseMutex() rinuncia alla proprietà del mutex M Mutex. Quando il thread T1 perderà il controllo del processore, il sistema potrà assegnarlo a uno dei thread in attesa del mutex M. Solo uno lo otterrà a sua volta, mentre gli altri in attesa di M rimarranno bloccati |
Un Mutex M gestisce l'accesso a una risorsa condivisa R. Un thread richiede la risorsa R tramite M.WaitOne() e la restituisce tramite M.ReleaseMutex(). Una sezione critica di codice che deve essere eseguita da un solo thread alla volta è una risorsa condivisa. La sincronizzazione dell’esecuzione della sezione critica può avvenire in questo modo:
dove M è un oggetto Mutex. È importante non dimenticare di liberare un Mutex che non serve più, in modo che un altro thread possa accedere alla sezione critica; in caso contrario, i thread in attesa del Mutex mai liberato non avranno mai accesso al processore.
Se applichiamo all’esempio precedente quanto abbiamo appena visto, la nostra applicazione diventa la seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program3 {
// variabili di classe
static int cptrThreads = 0; // contatore dei thread
static Mutex synchro = new Mutex(); // oggetto di sincronizzazione
//main
public static void Main(string[] args) {
...
}
public static void Incrémente() {
....
synchro.WaitOne();
try {
...
} finally {
...
synchro.ReleaseMutex();
}
}
}
}
- riga 9: l’oggetto di sincronizzazione dei thread è ora un Mutex.
- riga 18: inizio della sezione critica – solo un thread deve entrarvi. Ci si blocca finché il Mutex synchro non è libero.
- riga 33: poiché un Mutex deve sempre essere liberato, indipendentemente dal verificarsi o meno di un'eccezione, la sezione critica viene gestita con un try / finally per liberare il Mutex nel finally.
- riga 23: il Mutex viene liberato una volta superata la sezione critica.
I risultati ottenuti sono gli stessi di prima.
10.5.4. La classe AutoResetEvent
Un oggetto AutoResetEvent è una barriera che lascia passare un solo thread alla volta, come i due strumenti precedenti lock e Mutex. Si costruisce un oggetto AutoResetEvent nel modo seguente:
Il valore booleano état indica se la barriera è chiusa (false) o aperta (true). Un thread che intenda superare la barriera lo indicherà nel modo seguente:
- se la barriera è aperta, il thread la attraversa e la barriera viene richiusa dietro di lui. Se ci fossero più thread in attesa, si ha la certezza che ne passerà solo uno.
- Se la barriera è chiusa, il thread viene bloccato. Un altro thread la aprirà quando sarà il momento. Questo momento dipende interamente dal problema da risolvere. La barriera verrà aperta dall’operazione:
Può capitare che un thread voglia chiudere una barriera. Potrà farlo tramite:
Se nell’esempio precedente si sostituisce l’oggetto Mutex con un oggetto di tipo AutoResetEvent, il codice diventa il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program4 {
// variabili di classe
static int cptrThreads = 0; // contatore di thread
static EventWaitHandle synchro = new AutoResetEvent(false); // oggetto di sincronizzazione
//main
public static void Main(string[] args) {
....
// si apre la barriera della sezione critica
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} ouvre la barrière de la section critique", DateTime.Now, Thread.CurrentThread.Name);
synchro.Set();
// attesa della fine dei thread
...
// visualizzazione del contatore
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// aumenta il contatore dei thread
// viene richiesto un accesso esclusivo al contatore
...
synchro.WaitOne();
try {
...
} finally {
// si rilascia la risorsa
...
synchro.Set();
}
}
}
}
- riga 9: la barriera viene creata chiusa. Verrà aperta dal thread Main alla riga 16.
- riga 27: il thread incaricato di incrementare il contatore dei thread richiede l’autorizzazione per entrare nella sezione critica. I vari thread si accumuleranno davanti alla barriera chiusa. Quando il thread Main la aprirà, uno dei thread in attesa potrà passare.
- riga 33: una volta terminato il proprio lavoro, riapre la barriera consentendo l’ingresso a un altro thread.
Si ottengono risultati analoghi ai precedenti.
10.5.5. La classe Interlocked
La classe Interlocked consente di rendere atomico un gruppo di operazioni. In un gruppo di operazioni atomique, o tutte le operazioni vengono eseguite dal thread che esegue il gruppo oppure nessuna. Non si rimane in uno stato in cui alcune sono state eseguite e altre no. Gli oggetti di sincronizzazione lock, Mutex, AutoResetEvent hanno tutti lo scopo di rendere atomique un gruppo di operazioni. Questo risultato si ottiene a costo del blocco dei thread. La classe Interlocked consente, per operazioni semplici ma piuttosto frequenti, di evitare il blocco dei thread. La classe Interlocked offre i seguenti metodi statici:

Il metodo Increment ha la seguente firma:
Consente di incrementare di 1 il parametro location. L'operazione è garantita atomique.
Il nostro programma di conteggio dei thread può quindi essere il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program5 {
// variabili di classe
static int cptrThreads = 0; // contatore dei thread
//main
public static void Main(string[] args) {
...
}
public static void Incrémente() {
// incrementa il contatore dei thread
Interlocked.Increment(ref cptrThreads);
}
}
}
- riga 17: il contatore dei thread viene incrementato in modo atomico.
10.6. Accessi concorrenti a risorse condivise multiple
10.6.1. Un esempio
Nei nostri esempi precedenti, una singola risorsa era condivisa dai diversi thread. La situazione può complicarsi se le risorse sono più di una e sono interdipendenti. In particolare, può verificarsi una situazione di deadlock. Questa situazione, nota anche come deadlock, è quella in cui due thread si attendono a vicenda. Consideriamo le seguenti azioni che si susseguono nel tempo:
- un thread T1 acquisisce il controllo di un mutex M1 per accedere a una risorsa condivisa R1
- un thread T2 acquisisce il controllo di un mutex M2 per accedere a una risorsa condivisa R2
- il thread T1 richiede il mutex M2. Viene bloccato.
- il thread T2 richiede il mutex M1. È bloccato.
In questo caso, i thread T1 e T2 si aspettano a vicenda. Questo caso si verifica quando i thread necessitano di due risorse condivise: la risorsa R1 controllata dal mutex M1 e la risorsa R2 controllata dal mutex M2. Una possibile soluzione consiste nel richiedere entrambe le risorse contemporaneamente tramite un unico mutex M. Tuttavia, ciò non è sempre possibile se, ad esempio, comporta un lungo blocco di una risorsa costosa. Un’altra soluzione consiste nel far sì che un thread in possesso di M1, non potendo ottenere M2, rilasci M1 per evitare il deadlock.
- Abbiamo un array in cui alcuni thread inseriscono dati (gli scrittori) e altri li leggono (i lettori).
- Gli scrittori sono uguali tra loro ma esclusivi: un solo scrittore alla volta può inserire i propri dati nell’array.
- I lettori sono uguali tra loro ma esclusivi: un solo lettore alla volta può leggere i dati inseriti nell'array.
- Un lettore può leggere i dati dell’array solo quando un scrivitore li ha inseriti al suo interno e uno scrivitore può inserire nuovi dati nell’array solo quando quelli presenti sono stati letti da un lettore.
Si possono distinguere due risorse condivise:
- la tabella in scrittura: un solo scrittore alla volta deve potervi accedere.
- la tabella in lettura: un solo lettore alla volta deve potervi accedere.
e un ordine di utilizzo di queste risorse:
- un lettore deve sempre seguire uno scrittore.
- un autore deve sempre seguire un lettore, tranne la prima volta.
È possibile controllare l'accesso a queste due risorse con due barriere di tipo AutoResetEvent:
- la barriera peutEcrire controllerà l'accesso degli scrittori alla tabella.
- La barriera peutLire controllerà l'accesso dei lettori alla bacheca.
- La barriera peutEcrire sarà inizialmente aperta, consentendo così il passaggio di un primo utente e bloccando tutti gli altri.
- La barriera peutLire verrà creata inizialmente chiusa, bloccando tutti i lettori.
- Quando uno scrittore avrà terminato il proprio lavoro, aprirà la barriera peutLire per far entrare un lettore.
- Quando un lettore avrà terminato il proprio lavoro, aprirà la barriera peutEcrire per far entrare uno scrittore.
Il programma che illustra questa sincronizzazione basata sugli eventi è il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program {
// utilizzo di thread di lettura e scrittura
// illustra l'uso degli eventi di sincronizzazione
// variabili di classe
static int[] data = new int[3]; // risorsa condivisa tra thread di lettura e thread di scrittura
static Random objRandom = new Random(DateTime.Now.Second); // un generatore di numeri casuali
static AutoResetEvent peutLire; // indica che è possibile leggere il contenuto di data
static AutoResetEvent peutEcrire; // indica che è possibile scrivere il contenuto di data
//main
public static void Main(string[] args) {
// il numero di thread da generare
const int nbThreads = 2;
// inizializzazione dei flag
peutLire = new AutoResetEvent(false); // non è ancora possibile leggere
peutEcrire = new AutoResetEvent(true); // è già possibile scrivere
// creazione dei thread di lettura
Thread[] lecteurs = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creazione
lecteurs[i] = new Thread(Lire);
lecteurs[i].Name = "L" + i.ToString();
// avvio
lecteurs[i].Start();
}
// creazione dei thread di scrittura
Thread[] écrivains = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creazione
écrivains[i] = new Thread(Ecrire);
écrivains[i].Name = "E" + i.ToString();
// avvio
écrivains[i].Start();
}
//fine mano
Console.WriteLine("Fin de Main...");
}
// lettura del contenuto dell'array
public static void Lire() {
...
}
// scrittura nell'array
public static void Ecrire() {
....
}
}
}
- riga 11: l’array data è la risorsa condivisa tra i thread di lettura e di scrittura. È condivisa in lettura dai thread di lettura e in scrittura dai thread di scrittura.
- riga 13: l’oggetto peutLire serve a segnalare ai thread di lettura che possono leggere l’array data. Viene impostato a vero dal thread di scrittura che ha riempito l’array data. Viene inizializzato a false, riga 23. È necessario che un thread di scrittura compili prima l’array prima di far passare l’evento da peutLire a vrai.
- riga 14: l’oggetto peutEcrire serve a segnalare ai thread di scrittura che possono scrivere nell’array data. Viene impostato a vero dal thread di lettura che ha utilizzato l’intero array data. Viene inizializzato a true, riga 24. Infatti, l'array data è libero per la scrittura.
- righe 27-34: creazione e avvio dei thread di lettura
- righe 37-44: creazione e avvio dei thread di scrittura
Il metodo Lire eseguito dai thread di lettura è il seguente:
public static void Lire() {
// monitoraggio
Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// è necessario attendere l'autorizzazione alla lettura
peutLire.WaitOne();
// lettura tabella
for (int i = 0; i < data.Length; i++) {
//attesa di 1 s
Thread.Sleep(1000);
// visualizzazione
Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// è possibile scrivere
peutEcrire.Set();
// monitoraggio
Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
- riga 5: si attende che un thread di scrittura segnali che l’array è stato riempito. Quando verrà ricevuto questo segnale, solo uno dei thread di lettura in attesa di tale segnale potrà procedere.
- righe 7-12: elaborazione dell’array data con un Sleep al centro per costringere il thread a cedere il processore.
- riga 14: indica ai thread di scrittura che l'array è stato letto e che può essere riempito nuovamente.
Il metodo Ecrire eseguito dai thread di scrittura è il seguente:
public static void Ecrire() {
// monitoraggio
Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// è necessario attendere l'autorizzazione alla scrittura
peutEcrire.WaitOne();
// scrittura tabella
for (int i = 0; i < data.Length; i++) {
//attesa di 1 s
Thread.Sleep(1000);
// visualizzazione
data[i] = objRandom.Next(0, 1000);
Console.WriteLine("{0:hh:mm:ss} : L'écrivain {1} a écrit le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// è possibile leggere
peutLire.Set();
// monitoraggio
Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
- riga 5: si attende che un thread di lettura segnali che l’array è stato letto. Quando verrà ricevuto questo segnale, solo uno dei thread di scrittura in attesa di tale segnale potrà passare.
- righe 7-13: elaborazione dell’array data con un Sleep al centro per costringere il thread a cedere il processore.
- riga 15: indica ai thread di lettura che l'array è stato riempito e che può essere letto nuovamente.
L'esecuzione produce i seguenti risultati:
Si possono notare i seguenti punti:
- c'è effettivamente un solo lettore alla volta, sebbene questo perda il processore nella sezione critica Lire
- c'è effettivamente un solo scrittore alla volta, sebbene questo perda il processore nella sezione critica Ecrire
- un lettore legge solo quando c'è qualcosa da leggere nell'array
- un scrivitore scrive solo quando l’array è stato letto interamente
10.6.2. La classe Monitor
Nell'esempio precedente:
- ci sono due risorse condivise da gestire
- per una data risorsa, i thread sono uguali.
Quando i thread di scrittura sono bloccati sull'istruzione peutEcrire.WaitOne, uno di essi, a caso, viene sbloccato dall'operazione peutEcrire.Set. Se l'operazione precedente deve aprire la barriera a un thread di scrittura specifico, le cose si complicano.
Si può fare un paragone con un ufficio che accoglie il pubblico agli sportelli, dove ogni sportello è specializzato. Quando il cliente arriva, prende un biglietto dal distributore per lo sportello X e poi va a sedersi. Ogni biglietto è numerato e i clienti vengono chiamati in base al loro numero tramite un altoparlante. Durante l’attesa, il cliente fa ciò che vuole. Può leggere o sonnecchiare. Viene svegliato ogni volta dall’altoparlante che annuncia che il n. Y è chiamato allo sportello X. Se si tratta di lui, il cliente si alza e si reca allo sportello X, altrimenti continua a fare ciò che stava facendo.
In questo caso si può procedere in modo analogo. Prendiamo l’esempio degli scrittori:
i loro thread sono bloccati | |
Il thread che utilizzava l'array in lettura segnala ai thread di scrittura che l'array è disponibile. Esso stesso o un altro thread ha bloccato il thread di scrittura che deve superare la barriera. | |
Ogni thread verifica se è stato selezionato. In caso affermativo, supera la barriera. In caso contrario, torna in attesa. |
La classe Monitor consente di implementare questo scenario.

Descriviamo ora una struttura standard (pattern), proposta nel capitolo Threading del libro C# 3.0 citato nell’introduzione di questo documento, in grado di risolvere i problemi relativi alle barriere con condizioni di accesso.
- Innanzitutto, i thread che condividono una risorsa (lo sportello, ...) vi accedono tramite un oggetto che chiameremo token. Per aprire la barriera che conduce allo sportello, è necessario disporre del token per aprirla e c’è un solo token. I thread devono quindi scambiarsi il token tra loro.
- Per recarsi allo sportello, i thread richiedono innanzitutto il token:
Se il gettone è libero, viene assegnato al thread che ha eseguito l’operazione precedente; in caso contrario, il thread viene messo in attesa del gettone.
- Se l’accesso allo sportello avviene in modo non ordinato, c.a.d. nel caso in cui non sia rilevante chi entra, l’operazione precedente è sufficiente. Il thread in possesso del token si reca allo sportello. Se l’accesso avviene in modo ordinato, il thread in possesso del token verifica di soddisfare la condizione per recarsi allo sportello:
Se il thread non è quello atteso allo sportello, cede il proprio turno restituendo il token. Passa quindi in uno stato di blocco. Verrà riattivato non appena il token tornerà ad essere disponibile per lui. A quel punto verificherà nuovamente se soddisfa la condizione per recarsi allo sportello. L'operazione Monitor.Wait(token), che rilascia il token, può essere eseguita solo se il thread è proprietario del token. In caso contrario, viene generata un'eccezione.
- Il thread che verifica la condizione per passare allo sportello lo fa:
- // lavoro allo sportello
- ....
Prima di lasciare lo sportello, il thread deve restituire il proprio token, altrimenti i thread bloccati in attesa di esso rimarranno tali a tempo indeterminato. Esistono due situazioni diverse:
- la prima situazione è quella in cui il thread in possesso del token è anche quello che segnala ai thread in attesa del token che questo è libero. Lo farà nel modo seguente:
Riga 6: riattiva i thread in attesa del token. Questa riattivazione significa che diventano idonei a ricevere il token. Ciò non significa che lo ricevano immediatamente. Riga 8: il token viene rilasciato. Tutti i thread idonei riceveranno a turno il token, in modo indeterministico. Ciò darà loro l’opportunità di verificare nuovamente se soddisfano la condizione di accesso. Il thread che ha liberato il token ha modificato tale condizione alla riga 4 per consentire l’ingresso di un nuovo thread. Il primo che la soddisfa mantiene il token e si presenta allo sportello a sua volta.
- La seconda situazione è quella in cui il thread in possesso del token non è quello che deve segnalare ai thread in attesa del token che questo è libero. Deve comunque rilasciarlo perché il thread incaricato di inviare tale segnale deve essere in possesso del token. Lo farà tramite l’operazione:
Il token è ora disponibile, ma i thread che lo stanno aspettando (hanno eseguito un’operazione Wait(token)) non ne vengono avvisati. Questo compito è affidato a un altro thread che, a un certo punto, eseguirà un codice simile al seguente:
In definitiva, la struttura standard proposta nel capitolo Threading del libro C# 3.0 è la seguente:
- definire il token di accesso allo sportello:
- richiedere l’accesso allo sportello:
lock(jeton){
while (! jeNeSuisPasCeluiQuiEstAttendu)
Monitor.Wait(jeton);
}
// passaggio allo sportello
...
è equivalente a
Si noti che in questo schema il token viene rilasciato immediatamente, non appena viene superata la barriera. A quel punto, un altro thread può verificare la condizione di accesso. La struttura precedente consente quindi l’accesso a tutti i thread che verificano la condizione di accesso. Se ciò non è desiderato, si può scrivere:
lock(jeton){
while (! jeNeSuisPasCeluiQuiEstAttendu)
Monitor.Wait(jeton);
// passaggio allo sportello
...
}
dove il token viene rilasciato solo dopo il passaggio allo sportello.
- modificare la condizione di accesso allo sportello e avvisare gli altri thread
lock(jeton){
// modifica della condizione di accesso allo sportello
...
// notifica ai thread in attesa del token
Monitor.PulseAll(jeton);
}
Nel codice sopra riportato, la condizione di accesso può essere modificata solo dal thread in possesso del token. Si potrebbe anche scrivere:
// modifica della condizione di accesso allo sportello
...
// notificarlo ai thread in attesa del token
Monitor.PulseAll(jeton);
// rilasciare il token
Monitor.Exit(jeton);
se il thread possiede già il token.
Grazie a queste informazioni, possiamo riscrivere l'applicazione di lettura/scrittura stabilendo un ordine tra lettori e scrittori per l'accesso ai rispettivi channel. Il codice è il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program2 {
// utilizzo di thread di lettura e scrittura
// illustra l'uso degli eventi di sincronizzazione
// variabili di classe
static int[] data = new int[3]; // risorsa condivisa tra thread di lettura e thread di scrittura
static Random objRandom = new Random(DateTime.Now.Second); // un generatore di numeri casuali
static object peutLire = new object(); // indica che è possibile leggere il contenuto di data
static object peutEcrire = new object(); // indica che è possibile scrivere il contenuto di data
static bool lectureAutorisée = false; // per autorizzare la lettura dell'array
static bool écritureAutorisée = false; // per autorizzare la scrittura nell'array
static string[] ordreLecture; // stabilisce l'ordine dei lettori
static string[] ordreEcriture; // stabilisce l'ordine degli scrittori
static int lecteurSuivant = 0; // indica il numero del lettore successivo
static int écrivainSuivant = 0; // indica il numero del successivo scrittore
//main
public static void Main(string[] args) {
// il numero di thread da generare
const int nbThreads = 5;
// creazione dei thread di lettura
Thread[] lecteurs = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creazione
lecteurs[i] = new Thread(Lire);
lecteurs[i].Name = "L" + i.ToString();
// avvio
lecteurs[i].Start();
}
// creazione dell'ordine di lettura
ordreLecture = new string[nbThreads];
for (int i = 0; i < nbThreads; i++) {
ordreLecture[i] = lecteurs[nbThreads - i - 1].Name;
Console.WriteLine("Le lecteur {0} est en position {1}", ordreLecture[i], i);
}
// creazione dei thread di scrittura
Thread[] écrivains = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creazione
écrivains[i] = new Thread(Ecrire);
écrivains[i].Name = "E" + i.ToString();
// avvio
écrivains[i].Start();
}
// creazione dell'ordine di scrittura
ordreEcriture = new string[nbThreads];
for (int i = 0; i < nbThreads; i++) {
ordreEcriture[i] = écrivains[i].Name;
Console.WriteLine("L'écrivain {0} est en position {1}", ordreEcriture[i], i);
}
// autorizzazione alla scrittura
lock (peutEcrire) {
écritureAutorisée = true;
Monitor.Pulse(peutEcrire);
}
//fine della mano
Console.WriteLine("Fin de Main...");
}
// lettura del contenuto della tabella
public static void Lire() {
...
}
// scrittura nell'array
public static void Ecrire() {
...
}
}
}
L'accesso allo slot di lettura è condizionato dai seguenti elementi:
- riga 13: il token peutLire
- riga 15: il valore booleano lectureAutorisée
- riga 17: l’array ordinato dei lettori. I lettori si recano allo sportello di lettura nell’ordine indicato da questo array che contiene i loro nomi.
- riga 19: lecteurSuivant indica il numero del prossimo lettore autorizzato a recarsi allo sportello.
L'accesso allo sportello di scrittura è subordinato ai seguenti elementi:
- riga 14: il gettone peutEcrire
- riga 16: il valore booleano écritureAutorisée
- riga 18: l'array ordinato degli scrittori. Gli scrittori si recano allo sportello di scrittura nell'ordine di questo array che contiene i loro nomi.
- riga 20: écrivainSuivant indica il numero del prossimo scrivente autorizzato a recarsi allo sportello.
Gli altri elementi del codice sono i seguenti:
- righe 29-36: creazione e avvio dei thread di lettura. Saranno tutti bloccati poiché la lettura non è consentita (riga 15).
- righe 39-43: il loro ordine di accesso allo sportello avverrà in ordine inverso rispetto alla loro creazione.
- righe 46-53: creazione e avvio dei thread di scrittura. Saranno tutti bloccati poiché la scrittura non è consentita (riga 16).
- righe 56-60: il loro ordine di passaggio allo sportello avverrà nell'ordine di creazione.
- riga 64: si autorizza la scrittura
- riga 65: si avvisano gli scrittori che qualcosa è cambiato.
Il metodo Lire è il seguente:
public static void Lire() {
// monitoraggio
Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// è necessario attendere l'autorizzazione di lettura
lock (peutLire) {
while (!lectureAutorisée || ordreLecture[lecteurSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutLire);
}
// lettura tabella
for (int i = 0; i < data.Length; i++) {
//attesa di 1 s
Thread.Sleep(1000);
// visualizzazione
Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// lettore successivo
lectureAutorisée = false;
lecteurSuivant++;
// si avvisano gli scrittori che possono scrivere
lock (peutEcrire) {
écritureAutorisée = true;
Monitor.PulseAll(peutEcrire);
}
// monitoraggio
Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
}
- l'intero accesso allo sportello è controllato dal lock delle righe 5-27. Il lettore che preleva il gettone lo conserva per tutta la durata del suo passaggio allo sportello
- righe 6-8: un lettore che ha acquisito il gettone alla riga 5 lo rilascia se la lettura non è autorizzata o se non è il suo turno di passare.
- righe 10-15: passaggio allo sportello (elaborazione della tabella)
- righe 17-18: il thread modifica le condizioni di accesso allo sportello di lettura. Si noti che possiede ancora il token di lettura e che queste modifiche non consentono ancora a un lettore di passare.
- righe 20-23: il thread modifica le condizioni di accesso allo sportello di scrittura e avvisa tutti gli scrittori in attesa che qualcosa è cambiato.
- riga 27: il thread lock termina, il token peutLire viene rilasciato. Un thread di lettura potrebbe quindi acquisirlo alla riga 5, ma non supererebbe la condizione di accesso poiché il valore booleano lectureAutorisée è falso. Inoltre, tutti i thread in attesa del token peutLire rimangono in attesa poiché l’operazione PulseAll(peutLire) non è ancora avvenuta.
Il metodo Ecrire è il seguente:
public static void Ecrire() {
// monitoraggio
Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// bisogna attendere l'autorizzazione alla scrittura
lock (peutEcrire) {
while (!écritureAutorisée || ordreEcriture[écrivainSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutEcrire);
}
// scrittura tabella
for (int i = 0; i < data.Length; i++) {
//attesa 1 s
Thread.Sleep(1000);
// visualizzazione
data[i] = objRandom.Next(0, 1000);
Console.WriteLine("{0:hh:mm:ss} : L'écrivain {1} a écrit le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// scrittore successivo
écritureAutorisée = false;
écrivainSuivant++;
// si riattivano i lettori in attesa del token peutLire
lock (peutLire) {
lectureAutorisée = true;
Monitor.PulseAll(peutLire);
}
// monitoraggio
Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
}
- l'intero accesso allo sportello di scrittura è controllato dal codice lock delle righe 5-27. Lo scrittore che recupera il token lo conserva per tutta la durata del suo passaggio allo sportello
- righe 6-8: un operatore che ha acquisito il token alla riga 5 lo rilascia se la scrittura non è autorizzata o se non è il suo turno.
- righe 10-16: passaggio allo sportello (utilizzo dell'array)
- righe 18-19: il thread modifica le condizioni di accesso allo sportello di scrittura. Si noti che possiede ancora il token di scrittura e che queste modifiche non consentono ancora a uno scrittore di passare.
- righe 21-24: il thread modifica le condizioni di accesso allo sportello di lettura e avvisa tutti i lettori in attesa che qualcosa è cambiato.
- riga 27: il thread lock termina, il token peutEcrire viene rilasciato. Un thread di scrittura potrebbe quindi acquisirlo alla riga 5, ma non supererebbe la condizione di accesso poiché il valore booleano écritureAutorisée è falso. Inoltre, tutti i thread in attesa del token peutEcrire rimangono in attesa di una nuova operazione PulseAll(peutEcrire).
Ecco un esempio di esecuzione:
10.7. I pool di thread
Finora, per gestire i thread:
- li abbiamo creati con Thread T = new Thread(...)
- e poi li abbiamo eseguiti tramite T.Start()
Nel capitolo "Database" abbiamo visto che con alcuni SGBD era possibile disporre di pool di connessioni aperte:
- n le connessioni vengono aperte all’avvio del pool
- quando un thread richiede una connessione, gli viene assegnata una delle connessioni aperte del pool
- quando il thread chiude la connessione, questa non viene chiusa ma restituita al pool
L'uso di un pool di connessioni è trasparente a livello di codice. Il vantaggio risiede nel miglioramento delle prestazioni: l'apertura di una connessione comporta un costo elevato. In questo caso, 10 connessioni aperte possono gestire centinaia di richieste.
Esiste un sistema analogo per i thread:
- min thread vengono creati all'avvio del pool. Il valore di min viene impostato con il metodo ThreadPool.SetMinThreads(min1,min2). Un pool di thread può essere utilizzato per eseguire attività bloccanti o non bloccanti, dette asincrone. Il primo parametro min1 imposta il numero di thread bloccanti, il secondo min2 il numero di thread asincroni. I valori attuali di questi due parametri possono essere ottenuti tramite ThreadPool.GetMinThreads(out min1,out min2).
- Se tale numero non è sufficiente, il pool creerà altri thread per soddisfare le richieste fino al limite di max thread. Il valore di max viene impostato con il metodo ThreadPool.SetMaxThreads(max1,max2). I due parametri hanno lo stesso significato del metodo SetMinThreads. I valori attuali di questi due parametri possono essere ottenuti tramite ThreadPool.GetMaxThreads(out max1,out max2). Una volta raggiunto il numero di thread specificato da max1, le richieste di thread per attività bloccanti verranno messe in attesa di un thread libero nel pool.
Un pool di thread offre diversi vantaggi:
- come per il pool di connessioni, si risparmia sul tempo di creazione dei thread: 10 thread possono gestire centinaia di richieste.
- si garantisce la sicurezza dell’applicazione: fissando un numero massimo di thread, si evita il sovraccarico dell’applicazione dovuto a un numero eccessivo di richieste. Queste ultime verranno messe in coda.
Per assegnare un’attività a un thread del pool, si utilizza uno dei due metodi seguenti:
- ThreadPool.QueueWorkItem(WaitCallBack)
- ThreadPool.QueueWorkItem(WaitCallBack,object)
dove WaitCallBack è qualsiasi metodo con la firma void WaitCallBack(object). Il metodo 1 richiede a un thread di eseguire il metodo WaitCallBack senza passargli alcun parametro. Il metodo 2 fa la stessa cosa, ma passando un parametro di tipo object al metodo WaitCallBack.
Ecco un programma che illustra questi concetti:
using System;
using System.Threading;
namespace Chap8 {
class Program {
public static void Main() {
// inizializzazione del thread corrente
Thread main = Thread.CurrentThread;
// si assegna un nome al thread
main.Name = "Main";
// si utilizza un pool di thread
int min1, min2;
// si imposta il numero minimo di thread bloccanti
ThreadPool.GetMinThreads(out min1, out min2);
Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool : {0}", min1);
Console.WriteLine("Nombre minimum de tâches asynchrones dans le pool : {0}", min2);
ThreadPool.SetMinThreads(3, min2);
ThreadPool.GetMinThreads(out min1, out min2);
Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool après changement : {0}", min1);
// si imposta il numero massimo di thread bloccanti
int max1, max2;
ThreadPool.GetMaxThreads(out max1, out max2);
Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool : {0}", max1);
Console.WriteLine("Nombre maximum de tâches asynchrones dans le pool : {0}", max2);
ThreadPool.SetMaxThreads(5, max2);
ThreadPool.GetMaxThreads(out max1, out max2);
Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool après changement : {0}", max1);
// si eseguono 7 thread
for (int i = 0; i < 7; i++) {
// si avvia l'esecuzione del thread i in un pool
ThreadPool.QueueUserWorkItem(Sleep, new Data2 { Numéro = i.ToString(), Début = DateTime.Now, Durée = i + 10 });
}
// fine della routine
Console.Write("Tapez [entrée] pour terminer le thread {0} à {1:hh:mm:ss:FF}", main.Name, DateTime.Now);
// attesa
Console.ReadLine();
}
public static void Sleep(object infos) {
// si recupera il parametro
Data2 data = infos as Data2;
Console.WriteLine("A {2:hh:mm:ss:FF}, le thread n° {0} va dormir pendant {1} seconde(s)", data.Numéro, data.Durée,DateTime.Now);
// stato del pool
int cpt1, cpt2;
ThreadPool.GetAvailableThreads(out cpt1, out cpt2);
Console.WriteLine("Nombre de threads pour tâches bloquantes disponibles dans le pool : {0}", cpt1);
// sospensione per Durata secondi
Thread.Sleep(data.Durée * 1000);
// fine esecuzione
data.Fin = DateTime.Now;
Console.WriteLine("A {3:hh:mm:ss:FF}, le thread n° {0} se termine. Il était programmé pour durer {1} seconde(s). Il a duré {2} seconde(s)", data.Numéro, data.Durée, data.Fin - data.Début,DateTime.Now);
}
}
internal class Data2 {
// informazioni varie
public string Numéro { get; set; }
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
- righe 15-17: si richiede e si visualizza il numero minimo attuale dei due tipi di thread del pool di thread
- riga 18: si modifica il numero minimo di thread per le attività bloccanti: 2
- righe 19-21: si visualizzano i nuovi valori minimi
- righe 22-28: si procede allo stesso modo per impostare il numero massimo di thread per le attività bloccanti: 5
- righe 30-33: si eseguono 7 attività in un pool di 5 thread. 5 attività dovrebbero ottenere 1 thread, le prime 2 rapidamente poiché 2 thread sono sempre presenti, le altre 3 con un tempo di attesa di 0,5 secondi. 2 attività dovrebbero attendere che un thread si liberi.
- riga 32: le attività eseguono il metodo Sleep delle righe 40-54 passandole un parametro di tipo Data2 definito nelle righe 56-62.
- riga 40: il metodo Sleep eseguito dai task
- riga 42: si recupera il parametro passato al metodo Sleep.
- riga 43: il task si identifica sulla console
- righe 45-47: viene visualizzato il numero di thread attualmente disponibili. Vogliamo vedere come evolve.
- riga 49: il processo si arresta per alcuni secondi (processo bloccante).
- riga 52: quando riprende l'esecuzione, vengono visualizzate alcune informazioni sul suo account.
I risultati ottenuti sono i seguenti.
Per i numeri min e max relativi ai thread nel pool:
Per l’esecuzione dei 7 thread:
- righe 1-6: le prime 3 attività vengono eseguite a turno. Trovano immediatamente 1 thread disponibile (MinThreads=3) e poi entrano in stato di sospensione.
- righe 7-9: per le attività 3 e 4, il processo è leggermente più lungo. Per ciascuna di esse non era disponibile alcun thread. È stato necessario crearne uno. Questo meccanismo è possibile fino a 5 (MaxThreads=5).
- riga 10: non ci sono più thread disponibili: le attività 5 e 6 dovranno attendere.
- righe 11-12: l’attività 0 termina. L’attività 5 prende il proprio thread.
- righe 13-14: il task 1 termina. Il task 6 prende il proprio thread.
- righe 17-21: le attività terminano una dopo l’altra.
10.8. La classe BackgroundWorker
10.8.1. Esempio 1
La classe BackgroundWorker appartiene allo spazio dei nomi [System.ComponentModel]. Si utilizza come un thread, ma presenta alcune particolarità che, in certi casi, possono renderla più interessante rispetto alla classe [Thread]:
- emette i seguenti eventi:
- DoWork: un thread ha richiesto l'esecuzione di BackgroundWorker
- ProgressChanged: l'oggetto BackgroundWorker ha eseguito il metodo ReportProgress. Questo metodo serve a fornire una percentuale di esecuzione.
- RunWorkerCompleted: l'oggetto BackgroundWorker ha completato il proprio lavoro. Potrebbe averlo completato normalmente oppure a seguito di annullamento o di un'eccezione.
Questi eventi rendono il BackgroundWorker utile nelle interfacce grafiche: un’attività di lunga durata verrà affidata a un BackgroundWorker che potrà segnalare lo stato di avanzamento tramite l’evento ProgressChanged e il completamento tramite l’evento RunWorkerCompleted. Il lavoro che deve essere svolto dal BackgroundWorker verrà eseguito tramite un metodo associato all’evento DoWork.
- È possibile richiederne l'annullamento. In un'interfaccia grafica, un'attività di lunga durata potrà così essere annullata dall'utente.
- Gli oggetti BackgroundWorker appartengono a un pool e vengono riciclati in base alle necessità. Un'applicazione che necessita di un oggetto BackgroundWorker lo otterrà dal pool, che le fornirà un thread già esistente ma inutilizzato. Il riciclaggio dei thread in questo modo, anziché crearne uno nuovo ogni volta, migliora le prestazioni.
Utilizziamo questo strumento nell’applicazione precedente nel caso in cui l’accesso allo sportello non sia controllato:
using System;
using System.Threading;
using System.ComponentModel;
namespace Chap8 {
class Program2 {
// utilizzo di thread di lettura e scrittura
// illustra l'utilizzo simultaneo di risorse condivise e di sincronizzazione
// variabili di classe
const int nbThreads = 2; // numero totale di thread
static int nbLecteursTerminés = 0; // numero di thread terminati
static int[] data = new int[5]; // tabella condivisa tra thread di lettura e thread di scrittura
static object appli; // sincronizza l'accesso al numero di thread terminati
static Random objRandom = new Random(DateTime.Now.Second); // un generatore di numeri casuali
static AutoResetEvent peutLire; // segnala che è possibile leggere il contenuto dell'array
static AutoResetEvent peutEcrire; // segnala che è possibile scrivere nell'array
static AutoResetEvent finLecteurs; // segnala la fine dei lettori
//main
public static void Main(string[] args) {
// si assegna un nome al thread
Thread.CurrentThread.Name = "Main";
// inizializzazione dei flag
peutLire = new AutoResetEvent(false); // non è ancora possibile leggere
peutEcrire = new AutoResetEvent(true); // è già possibile scrivere
finLecteurs = new AutoResetEvent(false); // applicazione non terminata
// sincronizza l'accesso al contatore dei thread terminati
appli = new object();
// creazione dei thread di lettura
MyBackgroundWorker[] lecteurs = new MyBackgroundWorker[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creazione
lecteurs[i] = new MyBackgroundWorker();
lecteurs[i].Numéro = "L" + i;
lecteurs[i].DoWork += Lire;
lecteurs[i].RunWorkerCompleted += EndLecteur;
// avvio
lecteurs[i].RunWorkerAsync();
}
// creazione dei thread di scrittura
MyBackgroundWorker[] écrivains = new MyBackgroundWorker[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creazione
écrivains[i] = new MyBackgroundWorker();
écrivains[i].Numéro = "E" + i;
écrivains[i].DoWork += Ecrire;
// avvio
écrivains[i].RunWorkerAsync();
}
// attesa del completamento di tutti i thread
finLecteurs.WaitOne();
//fine della mano
Console.WriteLine("Fin de Main...");
}
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
...
}
// lettura del contenuto dell'array
public static void Lire(object sender, DoWorkEventArgs infos) {
...
}
// scrivere nell'array
public static void Ecrire(object sender, DoWorkEventArgs infos) {
...
}
}
// thread
internal class MyBackgroundWorker : BackgroundWorker {
// informazioni varie
public string Numéro { get; set; }
}
}
Ci limitiamo a descrivere le modifiche:
- la classe Thread è stata sostituita dalla classe MyBackgroundWorker nelle righe 79-82. La classe BackgroundWorker è stata derivata per assegnare un numero al thread. Si sarebbe potuto procedere in modo diverso passando un oggetto al metodo RunWorkerAsync alle righe 43 e 54, oggetto contenente il numero del thread.
- riga 58: il metodo Main termina dopo che tutti i thread di lettura hanno completato il proprio lavoro. A tal fine, alla riga 12, il contatore nbLecteursTerminés conta il numero di thread di lettura che hanno completato il proprio lavoro. Questo contatore viene incrementato dal metodo EndLecteur alle righe 63-65, che viene eseguito ogni volta che un thread di lettura termina. È questa procedura che controlla l’evento AutoResetEvent finLecteurs della riga 18, su cui si sincronizza, alla riga 59, il metodo Main.
- riga 16: poiché più thread di lettura potrebbero voler incrementare contemporaneamente il contatore nbLecteursTerminés, l’oggetto di sincronizzazione appli garantisce un accesso esclusivo a quest’ultimo. Si tratta di un caso improbabile, ma teoricamente possibile.
- righe 35-44: creazione dei thread di lettura
- riga 38: creazione del thread di tipo MyBackgroundWorker
- riga 39: gli viene assegnato un numero
- riga 40: gli viene assegnato il metodo Lire da eseguire
- riga 41: il metodo EndLecteur verrà eseguito al termine del thread
- riga 43: il thread viene avviato
- righe 47-55: creazione dei thread di scrittura
- riga 50: creazione del thread di tipo MyBackgroundWorker
- riga 51: gli viene assegnato un numero
- riga 52: gli viene assegnato il metodo Ecrire da eseguire
- riga 54: il thread viene avviato
I metodi Lire e Ecrire rimangono invariati. Il metodo EndLecteur viene eseguito al termine di ogni thread di lettura. Il suo codice è il seguente:
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
// incremento del numero di lettori completati
lock (appli) {
nbLecteursTerminés++;
if (nbLecteursTerminés == nbThreads)
finLecteurs.Set();
}
}
Il ruolo del metodo EndLecteur è quello di avvisare il metodo Main che tutti i lettori hanno completato il proprio lavoro.
- riga 4: il contatore nbLecteursTerminés viene incrementato.
- righe 5-6: se tutti i lettori hanno completato il proprio lavoro, l'evento finLecteurs viene impostato su vero per avvisare il metodo Main che attende tale evento.
- Poiché la procedura EndLecteur viene eseguita da più thread, la sezione critica precedente è protetta dalla clausola lock della riga 3.
L'esecuzione produce risultati analoghi a quelli della versione che utilizza i thread.
10.8.2. Esempio 2
Il codice seguente illustra altri aspetti della classe BackgroundWorker:
- la possibilità di annullare l'attività
- la segnalazione di un'eccezione generata all'interno dell'attività
- il passaggio di un parametro di I/O all'attività
using System;
using System.Threading;
using System.ComponentModel;
namespace Chap8 {
class Program3 {
// thread
static BackgroundWorker[] tâches = new BackgroundWorker[5];
public static void Main() {
// inizializzazione del thread corrente
Thread main = Thread.CurrentThread;
// assegnazione di un nome al thread
main.Name = "Main";
// creazione di thread
for (int i = 0; i < tâches.Length; i++) {
// si crea il thread n. i
tâches[i] = new BackgroundWorker();
// lo si inizializza
tâches[i].DoWork += Sleep;
tâches[i].RunWorkerCompleted += End;
tâches[i].WorkerSupportsCancellation = true;
// si avvia
tâches[i].RunWorkerAsync(new Data { Numéro = i, Début = DateTime.Now, Durée = i + 1 });
}
// si annulla l'ultimo thread
tâches[4].CancelAsync();
// fine della routine
Console.WriteLine("Fin du thread {0}, tapez [entrée] pour terminer...", main.Name);
Console.ReadLine();
return;
}
public static void Sleep(object sender, DoWorkEventArgs infos) {
...
}
public static void End(object sender, RunWorkerCompletedEventArgs infos) {
...
}
internal class Data {
// informazioni varie
public int Numéro { get; set; }
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
}
- riga 9: l'array di BackgroundWorker
- righe 18-27: creazione dei thread
- riga 20: creazione del thread
- riga 22: il thread eseguirà il metodo Sleep delle righe 39-41
- riga 23: il metodo End delle righe 43-45 verrà eseguito al termine del thread
- riga 24: il thread potrà essere annullato
- riga 26: il thread viene avviato con un parametro di tipo [Data], definito alle righe 49-52. Questo oggetto presenta i seguenti campi:
- Numéro (input): numero del thread
- Début (input): ora di inizio dell'esecuzione del thread
- Durée (ingresso): durata di esecuzione di Sleep
- Fin (uscita): fine dell'esecuzione del thread
- riga 29: il thread n. 4 viene annullato
Tutti i thread eseguono il seguente metodo Sleep:
public static void Sleep(object sender, DoWorkEventArgs infos) {
// si elabora il parametro "infos"
Data data = (Data)infos.Argument;
// eccezione per l'attività n. 3
if (data.Numéro == 3) {
throw new Exception("test....");
}
// sospensione per Durata secondi con un arresto ogni secondi
for (int i = 1; i <= data.Durée && !tâches[data.Numéro].CancellationPending; i++) {
// attesa di 1 secondo
Thread.Sleep(1000);
}
// fine dell'esecuzione
data.Fin = DateTime.Now;
// si inizializza il risultato
infos.Result = data;
infos.Cancel = tâches[data.Numéro].CancellationPending;
}
- riga 1: il metodo Sleep ha la firma standard dei gestori di eventi. Riceve due parametri:
- sender: l'emittente dell'evento, in questo caso il BackgroundWorker che esegue il metodo
- infos: di tipo DoWorkEventArgs, che fornisce informazioni sull'evento DoWork. Questo parametro serve sia a trasmettere informazioni al thread sia a recuperarne i risultati.
- riga 3: il parametro passato al metodo RunWorkerAsync del task si ritrova nella proprietà infos.Argument.
- righe 5-7: viene generata un'eccezione per il task n. 3
- righe 9-12: il thread «dorme» per Durée secondi, in intervalli di un secondo, per consentire il test di annullamento della riga 9. Ciò simula un’attività di lunga durata durante la quale il thread verificherebbe regolarmente se esiste una richiesta di annullamento. Per indicare che è stato annullato, il thread deve impostare la proprietà infos.Cancel su vero (riga 17).
- riga 16: il thread può restituire un risultato al thread che lo ha avviato. Inserisce tale risultato in infos.Result.
Una volta terminato, i thread eseguono il seguente metodo End:
public static void End(object sender, RunWorkerCompletedEventArgs infos) {
// si utilizza il parametro "infos" per visualizzare il risultato dell'esecuzione
// eccezione?
if (infos.Error != null) {
Console.WriteLine("Le thread {1} a rencontré l'erreur suivante : {0}", infos.Error.Message, sender);
} else
if (!infos.Cancelled) {
Data data = (Data)infos.Result;
Console.WriteLine("Thread {0} terminé : début {1:hh:mm:ss}, durée programmée {2} s, fin {3:hh:mm:ss}, durée effective {4}",
data.Numéro, data.Début, data.Durée, data.Fin, (data.Fin - data.Début));
} else {
Console.WriteLine("Thread {0} annulé", sender);
}
}
- riga 1: il metodo End ha la firma standard dei gestori di eventi. Riceve due parametri:
- sender: l’emittente dell’evento, in questo caso il BackgroundWorker che esegue il metodo
- infos: di tipo RunWorkerCompletedEventArgs, che fornisce informazioni sull'evento RunWorkerCompleted.
- riga 4: il campo infos.Error di tipo Exception viene compilato solo se si è verificata un'eccezione.
- riga 7: il campo infos.Cancelled, di tipo booleano, assume il valore true se il thread è stato annullato.
- riga 8: se non si è verificata alcuna eccezione o interruzione, allora infos.Result è il risultato del thread eseguito. L'utilizzo di questo risultato, qualora il thread sia stato annullato o abbia generato un'eccezione, provoca a sua volta un'eccezione. Pertanto, alle righe 5 e 13, non è possibile visualizzare il numero del thread annullato o che ha generato un'eccezione, poiché tale numero si trova in infos.Result. Questo problema può essere aggirato derivando la classe BackgroundWorker per inserirvi le informazioni da scambiare tra il thread chiamante e il thread chiamato, come è stato fatto nell’esempio precedente. Si utilizza quindi l'argomento sender, che rappresenta il BackgroundWorker, al posto dell'argomento infos.
I risultati dell’esecuzione sono i seguenti:
10.9. Dati locali a un thread
10.9.1. Il principio
Consideriamo un'applicazione a tre livelli:
![]() |
Supponiamo che l’applicazione sia multiutente, ad esempio un’applicazione web. Ogni utente è servito da un thread a lui dedicato. Il ciclo di vita del thread è il seguente:
- il thread viene creato o richiesto a un pool di thread per soddisfare una richiesta di un utente
- se tale richiesta richiede dei dati, il thread eseguirà un metodo del livello [ui] che chiamerà un metodo del livello [metier], il quale a sua volta chiamerà un metodo del livello [dao].
- Il thread restituisce la risposta all’utente. Successivamente viene eliminato oppure riciclato in un pool di thread.
Nell’operazione 2, potrebbe essere utile che il thread disponga di dati propri, c.a.d, non condivisi con gli altri thread. Questi dati potrebbero, ad esempio, appartenere allo specifico utente servito dal thread. Tali dati potrebbero quindi essere utilizzati nei diversi livelli [ui, metier, dao].
La classe Thread consente questo scenario grazie a una sorta di dizionario privato in cui le chiavi sarebbero di tipo LocalDataStoreSlot:
crea una voce nel dizionario privato del thread per la chiave name. | |
associa il valore data alla chiave name del dizionario privato del thread | |
recupera il valore associato alla chiave name dal dizionario privato del thread |
Un esempio di utilizzo potrebbe essere il seguente:
- per creare una coppia (clé,valeur) associata al thread corrente:
- per recuperare il valore associato a clé:
10.9.2. Applicazione del principio
Consideriamo la seguente applicazione a tre livelli:
![]() |
Supponiamo che il livello [dao] gestisca un database di articoli e che la sua interfaccia sia inizialmente la seguente:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- riga 5: per inserire un articolo nel database
- riga 6: per recuperare tutti gli articoli dal database
- riga 7: per eliminare tutti gli articoli dal database
Successivamente, emerge l’esigenza di un metodo per inserire un elenco di articoli tramite una transazione, poiché si desidera operare in modalità «tutto o niente»: o vengono inseriti tutti gli articoli oppure nessuno. È quindi possibile modificare l’interfaccia per integrare questa nuova esigenza:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
void insertArticles(Article[] articles);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- riga 6: per aggiungere un elenco di articoli nel database
Successivamente, per un’altra applicazione, emerge la necessità di eliminare un elenco di articoli salvato in una lista, sempre all’interno di una transazione. Si nota che, per rispondere a diverse esigenze aziendali, il livello [dao] è destinato ad ampliarsi. È possibile seguire un’altra strada:
- inserire nel livello [dao] solo le operazioni di base InsertArticle, DeleteArticle, UpdateArticle, SelectArticle, SelectArticles
- trasferire nel livello [métier] le operazioni di aggiornamento simultaneo di più articoli. Queste utilizzerebbero le operazioni elementari del livello [dao].
Il vantaggio di questa soluzione è che lo stesso livello [dao] potrebbe essere utilizzato senza modifiche con diversi livelli [metier]. Ciò comporta una difficoltà nella gestione della transazione che raggruppa gli aggiornamenti da effettuare in modo atomico sul database:
- la transazione deve essere avviata dal livello [metier] prima che questo chiami i metodi del livello [dao]
- i metodi del livello [dao] devono essere a conoscenza dell’esistenza della transazione per potervi partecipare, qualora essa esista
- la transazione deve essere terminata dal livello [métier].
Affinché i metodi del livello [dao] possano rilevare l'eventuale presenza di una transazione in corso, si potrebbe aggiungere la transazione come parametro di ciascun metodo del livello [dao]. Questo parametro apparirà quindi nella firma dei metodi dell'interfaccia, collegandola così a una specifica fonte di dati: il database. I dati locali del thread ci offrono una soluzione più elegante: il livello [métier] inserirà la transazione nei dati locali del thread ed è lì che il livello [dao] andrà a recuperarla. La firma dei metodi del livello [dao] non dovrà quindi essere modificata.
Implementiamo questa soluzione con il seguente progetto Visual Studio:
![]() |
![]() |
- in [1]: la soluzione nel suo complesso
- in [2]: i riferimenti utilizzati. Poiché il database [4] è un database SQL Server Compact, è necessario disporre del riferimento [System.Data.SqlServerCe].
- in [3]: i diversi livelli dell'applicazione.
Il database [4] è il database SQL Server Compact già utilizzato nel capitolo precedente, in particolare al paragrafo 9.3.1.
![]() |
La classe Articolo
Una riga della tabella [articles] precedente è incapsulata in un oggetto di tipo Article:
namespace Chap8 {
public class Article {
// proprietà
public int Id { get; set; }
public string Nom { get; set; }
public decimal Prix { get; set; }
public int StockActuel { get; set; }
public int StockMinimum { get; set; }
// costruttori
public Article() {
}
public Article(int id, string nom, decimal prix, int stockActuel, int stockMinimum) {
Id = id;
Nom = nom;
Prix = prix;
StockActuel = stockActuel;
StockMinimum = stockMinimum;
}
// identità
public override string ToString() {
return string.Format("[{0},{1},{2},{3},{4}]", Id, Nom, Prix, StockActuel, StockMinimum);
}
}
}
Interfaccia del livello [dao]
L'interfaccia IDao del livello [dao] sarà la seguente:
using System.Collections.Generic;
namespace Chap8 {
public interface IDao {
int InsertArticle(Article article);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- riga 5: per inserire un articolo nella tabella [articles]
- riga 6: per inserire tutte le righe della tabella [articles] in un elenco di oggetti Article
- riga 7: per eliminare tutte le righe della tabella [articles]
Interfaccia del livello [metier]
L'interfaccia IMetier del livello [metier] sarà la seguente:
using System.Collections.Generic;
namespace Chap8 {
interface IMetier {
void InsertArticlesInTransaction(Article[] articles);
void InsertArticlesOutOfTransaction(Article[] articles);
List<Article> GetAllArticles();
void DeleteAllArticles();
}
}
- riga 5: per inserire, all’interno di una transazione, un insieme di articoli
- riga 6: come sopra, ma senza transazione
- riga 7: per ottenere l'elenco di tutti gli articoli
- riga 8: per eliminare tutti gli articoli
Implementazione del livello [metier]
L'implementazione business dell'interfaccia IMetier sarà la seguente:
using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;
namespace Chap8 {
public class Metier : IMetier {
// livello [dao]
public IDao Dao { get; set; }
// stringa di connessione
public string ConnectionString { get; set; }
// inserimento di una tabella di articoli all’interno di una transazione
public void InsertArticlesInTransaction(Article[] articles) {
// si crea la connessione al database
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// apertura della connessione
connexion.Open();
// transazione
SqlCeTransaction transaction = null;
try {
// inizio transazione
transaction = connexion.BeginTransaction(IsolationLevel.ReadCommitted);
// si registra la transazione nel thread
Thread.SetData(Thread.GetNamedDataSlot("transaction"), transaction);
// inserimento degli articoli
foreach (Article article in articles) {
Dao.InsertArticle(article);
}
// conferma della transazione
transaction.Commit();
} catch {
// si annulla la transazione
if (transaction != null)
transaction.Rollback();
}
}
}
// inserimento di una tabella di articoli senza transazione
public void InsertArticlesOutOfTransaction(Article[] articles) {
// inserimento degli articoli
foreach (Article article in articles) {
Dao.InsertArticle(article);
}
}
// elenco degli articoli
public List<Article> GetAllArticles() {
return Dao.GetAllArticles();
}
// eliminazione di tutti gli articoli
public void DeleteAllArticles() {
Dao.DeleteAllArticles();
}
}
}
La classe presenta le seguenti proprietà:
- riga 9: un riferimento al livello [dao]
- riga 11: la stringa di connessione che consente di connettersi al database degli articoli
Commentiamo solo il metodo InsertArticlesInTransaction, l'unico che presenta delle difficoltà:
- riga 16: viene creata una connessione al database
- riga 18: la connessione viene aperta
- riga 23: viene creata una transazione
- riga 25: la transazione viene registrata nei dati locali del thread, associata alla chiave "transaction"
- righe 27-29: per ogni articolo da inserire viene chiamato il metodo di inserimento unitario del livello [dao]
- righe 21 e 32: l'intero processo di inserimento dell'array è controllato da un try/catch
- riga 31: se si arriva a questo punto, significa che non si è verificata alcuna eccezione. A questo punto si conferma la transazione.
- righe 34-35: si è verificata un'eccezione, si annulla la transazione
- riga 37: si esce dalla clausola using. La connessione aperta alla riga 18 viene chiusa automaticamente.
Implementazione del livello [dao]
L'implementazione DAO dell'interfaccia IDao sarà la seguente:
using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;
namespace Chap8 {
public class Dao : IDao {
// stringa di connessione
public string ConnectionString { get; set; }
// richieste
public string InsertText { get; set; }
public string DeleteAllText { get; set; }
public string GetAllText { get; set; }
// implementazione dell'interfaccia
// inserimento articolo
public int InsertArticle(Article article) {
// c'è una transazione in corso?
SqlCeTransaction transaction = Thread.GetData(Thread.GetNamedDataSlot("transaction")) as SqlCeTransaction;
// recuperare la connessione o crearla
SqlCeConnection connexion = null;
if (transaction != null) {
// recuperare la connessione
connexion = transaction.Connection as SqlCeConnection;
} else {
// crearla
connexion = new SqlCeConnection(ConnectionString);
connexion.Open();
}
try {
// preparazione del comando di inserimento
SqlCeCommand sqlCommand = new SqlCeCommand();
sqlCommand.Transaction = transaction;
sqlCommand.Connection = connexion;
sqlCommand.CommandText = InsertText;
sqlCommand.Parameters.Add("@nom", SqlDbType.NVarChar, 30);
sqlCommand.Parameters.Add("@prix", SqlDbType.Money);
sqlCommand.Parameters.Add("@sa", SqlDbType.Int);
sqlCommand.Parameters.Add("@sm", SqlDbType.Int);
sqlCommand.Parameters["@nom"].Value = article.Nom;
sqlCommand.Parameters["@prix"].Value = article.Prix;
sqlCommand.Parameters["@sa"].Value = article.StockActuel;
sqlCommand.Parameters["@sm"].Value = article.StockMinimum;
// esecuzione
return sqlCommand.ExecuteNonQuery();
} finally {
// se non si era in una transazione, si chiude la connessione
if (transaction == null) {
connexion.Close();
}
}
}
// elenco degli articoli
public List<Article> GetAllArticles() {
...
}
// eliminazione degli articoli
public void DeleteAllArticles() {
...
}
}
}
La classe presenta le seguenti proprietà:
- riga 9: la stringa di connessione che consente di connettersi al database degli articoli
- riga 11: il comando SQL per inserire un articolo
- riga 12: il comando SQL per eliminare tutti gli articoli
- riga 13: il comando SQL per recuperare tutti gli articoli
Queste proprietà saranno inizializzate dal seguente file di configurazione [App.config]:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<connectionStrings>
<add name="dbArticlesSqlServerCe" connectionString="Data Source=|DataDirectory|\dbarticles.sdf;Password=dbarticles;" />
</connectionStrings>
<appSettings>
<add key="insertText" value="insert into articles(nom,prix,stockactuel,stockminimum) values(@nom,@prix,@sa,@sm)"/>
<add key="getAllText" value="select id,nom,prix,stockactuel,stockminimum from articles"/>
<add key="deleteAllText" value="delete from articles"/>
</appSettings>
</configuration>
Commentiamo il metodo InsertArticle:
- riga 20: si recupera l'eventuale transazione che il livello [metier] potrebbe aver inserito nel thread
- righe 23-25: se la transazione è presente, si recupera la connessione a cui è stata associata.
- righe 26-30: in caso contrario, viene creata e aperta una nuova connessione.
- righe 33-44: si prepara il comando di inserimento. Questo viene configurato (cfr. riga g di App.config).
- riga 33: viene creato l'oggetto Command.
- riga 34: è associato alla transazione corrente. Se questa non esiste (transazione=null), equivale a eseguire il comando SQL senza una transazione esplicita. Si ricorda che in tal caso esiste comunque una transazione implicita. Con SQL Server CE, questa transazione implicita è per impostazione predefinita in modalità autocommit: l'ordine SQL diventa committé dopo la sua esecuzione.
- riga 35: l'oggetto Command è associato alla connessione corrente
- riga 36: viene definito il testo SQl da eseguire. Si tratta della query parametrizzata della riga g di App.config.
- righe 37-44: i 4 parametri della query vengono inizializzati
- riga 46: la query viene eseguita.
- righe 49-51: occorre ricordare che, se non era presente alcuna transazione, è stata aperta una nuova connessione al database (righe 26-30). In questo caso, essa deve essere chiusa. Se era presente una transazione, la connessione non deve essere chiusa poiché è il livello [metier] a gestirla.
Gli altri due metodi riprendono quanto visto nel capitolo «Database»:
// elenco articoli
public List<Article> GetAllArticles() {
// elenco degli articoli - inizialmente vuoto
List<Article> articles = new List<Article>();
// elaborazione della connessione
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// apertura connessione
connexion.Open();
// esegue sqlCommand con query select
SqlCeCommand sqlCommand = new SqlCeCommand(GetAllText, connexion);
using (SqlCeDataReader reader = sqlCommand.ExecuteReader()) {
// elaborazione del risultato
while (reader.Read()) {
// elaborazione della riga corrente
articles.Add(new Article(reader.GetInt32(0), reader.GetString(1), reader.GetDecimal(2), reader.GetInt32(3), reader.GetInt32(4)));
}
}
}
// restituzione del risultato
return articles;
}
// eliminazione degli articoli
public void DeleteAllArticles() {
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// apertura connessione
connexion.Open();
// esegue sqlCommand con richiesta di aggiornamento
new SqlCeCommand(DeleteAllText, connexion).ExecuteNonQuery();
}
}
L’applicazione di test [console]
L’applicazione di test [console] è la seguente:
using System;
using System.Configuration;
namespace Chap8 {
class Program {
static void Main(string[] args) {
// elaborazione del file di configurazione
string connectionString = null;
string insertText;
string getAllText;
string deleteAllText;
try {
// stringa di connessione
connectionString = ConfigurationManager.ConnectionStrings["dbArticlesSqlServerCe"].ConnectionString;
// altri parametri
insertText = ConfigurationManager.AppSettings["insertText"];
getAllText = ConfigurationManager.AppSettings["getAllText"];
deleteAllText = ConfigurationManager.AppSettings["deleteAllText"];
} catch (Exception e) {
Console.WriteLine("Erreur de configuration : {0}", e.Message);
return;
}
// creazione del livello [dao]
Dao dao = new Dao();
dao.ConnectionString = connectionString;
dao.DeleteAllText = deleteAllText;
dao.GetAllText = getAllText;
dao.InsertText = insertText;
// creazione livello [métier]
Metier metier = new Metier();
metier.Dao = dao;
metier.ConnectionString = connectionString;
// si crea una tabella articoli
Article[] articles = new Article[2];
for (int i = 0; i < articles.Length; i++) {
articles[i] = new Article(0, "article", 100, 10, 1);
}
// si eliminano tutti gli articoli
Console.WriteLine("Suppression de tous les articles...");
metier.DeleteAllArticles();
// si inserisce la tabella al di fuori della transazione
Console.WriteLine("Insertion des articles hors transaction...");
try {
metier.InsertArticlesOutOfTransaction(articles);
} catch (Exception e){
Console.WriteLine("Exception : {0}", e.Message);
}
// visualizzazione degli articoli
Console.WriteLine("Liste des articles");
AfficheArticles(metier);
// si eliminano tutti gli articoli
Console.WriteLine("Suppression de tous les articles...");
metier.DeleteAllArticles();
// si inserisce la tabella in una transazione
Console.WriteLine("Insertion des articles dans une transaction...");
metier.InsertArticlesInTransaction(articles);
// visualizza gli articoli
Console.WriteLine("Liste des articles");
AfficheArticles(metier);
}
private static void AfficheArticles(IMetier metier) {
// visualizza gli articoli
foreach(Article article in metier.GetAllArticles()){
Console.WriteLine(article);
}
}
}
}
- righe 12-22: viene utilizzato il file [App.config].
- righe 24-28: il livello [dao] viene istanziato e inizializzato
- righe 30-32: si procede allo stesso modo per il livello [metier]
- righe 34-37: viene creato un array di 2 articoli con lo stesso nome. La tabella [articles] del database SQL server [dbarticles.sdf] presenta un vincolo di unicità sul nome. L'inserimento del secondo articolo verrà quindi rifiutato. Se l'inserimento della tabella avviene al di fuori di una transazione, il primo articolo verrà prima inserito e poi rimarrà. Se l'inserimento della tabella avviene all'interno di una transazione, il primo articolo verrà prima inserito e poi rimosso, al momento della chiusura della transazione.
- righe 39-50: inserimento al di fuori di una transazione della tabella di 2 articoli e verifica.
- righe 52-59: come sopra, ma all’interno di una transazione
I risultati dell’esecuzione sono i seguenti:
- righe 5-6: l'inserimento al di fuori della transazione ha lasciato il primo articolo nel database
- riga 9: l'inserimento effettuato all'interno di una transazione non ha lasciato alcun articolo nel database
10.9.3. Conclusione
L'esempio precedente ha illustrato l'utilità dei dati locali a un thread per la gestione delle transazioni. Non va replicato così com’è. Framework come Spring, Nhibernate, ... utilizzano questa tecnica ma la rendono ancora più trasparente: è possibile per il livello [metier] utilizzare le transazioni senza che il livello [dao] ne sia a conoscenza. Non vi è quindi alcun oggetto Transaction nel codice del livello [dao]. Ciò si ottiene tramite una tecnica di proxy denominata AOP (Aspects Oriented Programming). Ancora una volta, non possiamo che incoraggiare il lettore a utilizzare questi framework.
10.10. Per approfondire...
Per approfondire il complesso argomento della sincronizzazione dei thread, si consiglia di leggere il capitolo Threading del libro C# 3.0 citato nell’introduzione del presente documento. In esso vengono illustrate numerose tecniche di sincronizzazione per diversi tipi di situazioni.







