10. Thread di esecuzione
10.1. La classe Thread
Quando un'applicazione viene avviata, viene eseguita in un flusso di esecuzione chiamato thread. La classe .NET che modella un thread è System.Threading.Thread e ha la seguente definizione:
Produttori
![]() |
Negli esempi seguenti, useremo solo i costruttori [1,3]. Il costruttore [1] ammette come parametro un metodo con firma [2], ovvero con un parametro di tipo object e che non restituisce alcun risultato. Il costruttore [3] accetta come parametro un metodo con firma [4], ovvero 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 richiede questa proprietà
- string Name: nome del thread
- bool IsAlive: indica se il thread è in esecuzione o meno.
Metodi
I metodi più comunemente utilizzati sono:
- Start(), Start(object obj): avvia l'esecuzione asincrona del thread, eventualmente passando informazioni in un oggetto.
- Abort(), Abort(object obj): per terminare forzatamente un thread
- Join() : il thread T1 che esegue T2.Join viene bloccato fino al completamento di T2. Esistono varianti per terminare l'attesa dopo un tempo prestabilito.
- Sleep(int n) : metodo statico - il thread che esegue il metodo viene sospeso per n millisecondi. Perde quindi il processore, che viene assegnato a un altro thread.
Diamo un'occhiata a una prima applicazione che dimostra l'esistenza di un thread principale di esecuzione, quello in cui si trova la funzione Main di una classe:
using System;
using System.Threading;
namespace Chap8 {
class Program {
static void Main(string[] args) {
// init current thread
Thread main = Thread.CurrentThread;
// display
Console.WriteLine("Thread courant : {0}", main.Name);
// we change the name
main.Name = "main";
// check
Console.WriteLine("Thread courant : {0}", main.Name);
// infinite loop
while (true) {
// display
Console.WriteLine("{0} : {1:hh:mm:ss}", main.Name, DateTime.Now);
// temporary shutdown
Thread.Sleep(1000);
}//while
}
}
}
- riga 8: recupera un riferimento al thread in cui è in esecuzione il metodo [main]
- righe 10-14: visualizza e modifica il suo nome
- righe 17-22: un ciclo che visualizza un messaggio ogni secondo
- riga 21: il thread in cui è in esecuzione il metodo [main] verrà sospeso per 1 secondo
I risultati sullo schermo sono i seguenti:
- riga 1: il thread corrente non aveva un nome
- riga 2: ora ne ha uno
- righe 3-7: visualizza ogni secondo
- riga 8: il programma viene interrotto da Ctrl-C.
10.2. Creazione di thread di esecuzione
È possibile avere applicazioni in cui parti di codice vengono eseguite "simultaneamente" in thread di esecuzione diversi. Quando diciamo che i thread vengono eseguiti simultaneamente, spesso si tratta di un termine improprio. Se la macchina ha un solo processore, come spesso accade, i thread condividono questo processore: ciascuno di essi vi ha accesso, a turno, per un breve periodo (pochi millisecondi). Ciò dà l'illusione di un'esecuzione parallela. La porzione di tempo assegnata a un thread dipende da vari fattori, tra cui la sua priorità, che ha un valore predefinito ma può anche essere impostata a livello di programmazione. Quando un thread ha il processore, lo utilizza normalmente per tutto il tempo assegnato. Tuttavia, può rilasciarlo in anticipo:
- attendo un evento (Wait, Join)
- mettendosi in stato di sospensione per un determinato periodo di tempo (Sleep)
- Un thread T viene inizialmente creato da uno dei generatori presentati sopra, ad esempio:
dove Start è un metodo con una delle due seguenti firme:
La creazione di un thread non ne determina l'avvio.
- Il thread T viene avviato tramite T.Start(): il metodo Start passato al costruttore di T verrà quindi eseguito dal thread T. Il programma che esegue T.Start() non attende il completamento del task T: passa immediatamente all'istruzione successiva. Ciò significa che due task vengono eseguiti in parallelo. In molti casi, devono poter comunicare tra loro per tenere traccia dello stato di avanzamento del loro lavoro congiunto. Questo è il problema della sincronizzazione dei thread.
- Una volta avviato, il thread T viene eseguito in modo autonomo. Si interromperà quando il task Start che sta eseguendo avrà terminato il proprio lavoro.
- Il thread T può essere forzato a terminare:
- T.Abort() richiede al thread T di terminare.
- È anche possibile attendere la fine della sua esecuzione tramite T.Join(). Si tratta di un'istruzione di blocco: il programma che la esegue rimane bloccato fino a quando il thread T non ha completato il proprio lavoro. Questo è un metodo di sincronizzazione.
Diamo un'occhiata al seguente programma:
using System;
using System.Threading;
namespace Chap8 {
class Program {
public static void Main() {
// init Current thread
Thread main = Thread.CurrentThread;
// name the Thread
main.Name = "Main";
// creation of execution threads
Thread[] tâches = new Thread[5];
for (int i = 0; i < tâches.Length; i++) {
// create thread i
tâches[i] = new Thread(Affiche);
// set the thread name
tâches[i].Name = i.ToString();
// start execution of thread i
tâches[i].Start();
}
// end of hand
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}",main.Name,DateTime.Now);
}
public static void Affiche() {
// display start of execution
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);
// sleep for 1 s
Thread.Sleep(1000);
// display end of run
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: assegnano un nome al thread che esegue il metodo [Main]
- righe 13-21: vengono creati ed eseguiti 5 thread. I riferimenti ai thread vengono memorizzati in un array per un successivo recupero. Ogni thread esegue il metodo Poster righe 27-35.
- riga 20: viene avviato il thread n. i. Questa operazione è non bloccante. Il thread n. i verrà eseguito in parallelo con il thread del metodo [Main] che lo ha lanciato.
- riga 24: il thread che esegue il metodo [Main] termina.
- righe 27-35: il metodo [Display] esegue le visualizzazioni. Visualizza il nome del thread che lo sta eseguendo, nonché gli orari di inizio e fine dell'esecuzione.
- riga 31: qualsiasi thread che esegue il metodo [Display] si fermerà per 1 secondo. Il processore verrà quindi assegnato a un altro thread in attesa di un processore. Al termine del secondo di pausa, il thread fermato sarà un candidato per il processore. Lo otterrà quando arriverà il suo turno. Ciò dipende da vari fattori, tra cui la priorità degli altri thread in attesa del processore.
I risultati sono i seguenti:
Questi risultati sono molto istruttivi:
- innanzitutto, possiamo vedere che l'avvio dell'esecuzione di un thread non è bloccante. Il Main ha avviato l'esecuzione di 5 thread in parallelo e ha completato la propria esecuzione prima di loro. L'operazione
// on lance l'exécution du thread i
tâches[i].Start();
avvia l'esecuzione del thread tasks[i], ma una volta fatto ciò, l'esecuzione prosegue immediatamente con l'istruzione successiva, senza attendere che il thread finisca di essere eseguito.
- tutti i thread creati devono eseguire il metodo Affiche. L'ordine di esecuzione è imprevedibile. Sebbene nell'esempio l'ordine di esecuzione sembri seguire l'ordine delle richieste di esecuzione, non è possibile trarne conclusioni generali . Il sistema operativo ha qui 6 thread e un processore. Distribuirà il processore a questi 6 thread secondo le proprie regole.
- I risultati sono una conseguenza del metodo Sleep. Nell'esempio, il thread 0 è il primo ad eseguire il metodo Affiche. Viene visualizzato il messaggio di inizio esecuzione, quindi viene eseguito il metodo Sleep che lo sospende per 1 secondo. A quel punto perde il processore, che diventa disponibile per un altro thread. L'esempio mostra che sarà il thread 1 a ottenerlo. Il thread 1 seguirà lo stesso percorso degli altri thread. Quando il secondo di sospensione del thread 0 è terminato, la sua esecuzione può riprendere. Il sistema gli assegna il processore e può terminare l'esecuzione del metodo Affiche.
Modifichiamo il nostro programma in modo che la funzione Main termini con le istruzioni:
// end of hand
Console.WriteLine("Fin du thread " + main.Name);
// stop all threads
Environment.Exit(0);
L'esecuzione del nuovo programma fornisce i seguenti risultati:
- righe 1-5: i thread creati dal Main iniziano l'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 e non solo il thread Main.
Se il thread Main desidera attendere che i thread da esso creati terminino l'esecuzione, può utilizzare la classe Join di Thread:
public static void Main() {
...
// we wait for all threads
for (int i = 0; i < tâches.Length; i++) {
// wait for thread i to finish execution
tâches[i].Join();
}
// end of hand
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... Infine, quando esce dal ciclo delle righe 2-5, tutti e 5 i thread che ha avviato sono terminati.
I risultati sono i seguenti:
- riga 11: il thread [Main] si è terminato dopo i thread che aveva avviato.
10.3. I vantaggi dei filati
Ora che abbiamo evidenziato l'esistenza di un thread predefinito, quello che esegue Main, e sappiamo come crearne di nuovi, diamo un'occhiata a cosa significano per noi i thread e perché li stiamo introducendo qui. C'è un tipo di applicazione che si presta bene all'uso dei thread, e sono le applicazioni client-server di Internet. Le presenteremo nel capitolo seguente. In un'applicazione client-server su Internet, un server sulla macchina S1 risponde alle richieste provenienti dai client sulle macchine remote C1, C2, ..., Cn.
![]() |
Ogni giorno utilizziamo applicazioni Internet corrispondenti a questo diagramma: servizi web, e-mail, consultazione di forum, trasferimento di file... Nel diagramma sopra riportato, il server S1 deve servire i client Ci contemporaneamente. Se prendiamo l’esempio di un server FTP (File Transfer Protocol) che distribuisce file ai propri client, sappiamo che un trasferimento di file può talvolta richiedere diversi minuti. Ovviamente, è fuori discussione che un cliente monopolizzi il server per tutto questo tempo. Di solito, il server crea tanti thread di esecuzione quanti sono i clienti. Ogni thread è quindi responsabile 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 un servizio simultaneo.
![]() |
In pratica, il server utilizza un pool di thread con un numero limitato di thread, ad esempio 50. Al 51° cliente viene quindi chiesto di attendere.
10.4. Scambio di informazioni tra thread
Negli esempi precedenti, un thread veniva inizializzato come segue:
dove Run era un metodo con la seguente firma:
È anche possibile utilizzare la seguente firma:
Ciò consente di trasmettere informazioni al thread avviato. Ad esempio,
avvierà il thread t, che a sua volta eseguirà il metodo Run ad esso associato, passando il parametro effettivo obj1. Ecco un esempio:
using System;
using System.Threading;
namespace Chap8 {
class Program4 {
public static void Main() {
// init Current thread
Thread main = Thread.CurrentThread;
// name the Thread
main.Name = "Main";
// creation of execution threads
Thread[] tâches = new Thread[5];
Data[] data = new Data[5];
for (int i = 0; i < tâches.Length; i++) {
// create thread i
tâches[i] = new Thread(Sleep);
// set the thread name
tâches[i].Name = i.ToString();
// start execution of thread i
tâches[i].Start(data[i] = new Data { Début = DateTime.Now, Durée = i+1 });
}
// we wait for all threads
for (int i = 0; i < tâches.Length; i++) {
// wait for thread i to finish execution
tâches[i].Join();
// result display
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));
}
// end of hand
Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
public static void Sleep(object infos) {
// parameter is retrieved
Data data = (Data)infos;
// sleep mode for Duration
Thread.Sleep(data.Durée*1000);
// end of execution
data.Fin = DateTime.Now;
}
}
internal class Data {
// miscellaneous information
public DateTime Début { get; set; }
public int Durée { get; set; }
public DateTime Fin { get; set; }
}
}
- righe 45-50: informazioni di tipo [Data] passate ai thread :
- Start: ora di inizio dell'esecuzione del thread - impostata dal thread di avvio
- Durata: durata in secondi dello Sleep eseguito dal thread lanciato - impostata dal thread di avvio
- End: ora di inizio dell'esecuzione del thread - impostata dal thread lanciato
- righe 35-43: il metodo Sleep eseguito dai thread ha la firma void Sleep(object obj). Il parametro effettivo obj sarà di tipo [Data] definito alla riga 45.
- righe 15-22: creazione di 5 thread
- riga 17: ogni thread è associato al metodo Sleep alla riga 35
- riga 21: un oggetto di tipo [Data] viene passato a Start che avvia il thread. In questo oggetto, abbiamo annotato l'ora di inizio dell'esecuzione del thread e la durata in secondi per cui deve rimanere in stato di sospensione. Questo oggetto viene memorizzato nella tabella alla riga 14.
- righe 24-30: il thread [Main] attende che tutti i thread che ha avviato terminino.
- 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 parametro del campo Duration viene utilizzato per impostare Sleep
- riga 41: il campo End del parametro viene inizializzato
I risultati sono i seguenti:
Questo esempio mostra che due thread possono scambiarsi informazioni:
- il thread di avvio può controllare l'esecuzione del thread avviato fornendogli informazioni
- il thread lanciato può restituire risultati al thread di lancio.
Affinché il thread lanciato sappia quando sono disponibili i risultati che sta aspettando, deve essere avvisato quando il thread lanciato ha terminato. Qui, ha atteso che terminasse utilizzando il Join. Esistono altri modi per ottenere lo stesso risultato. Li esamineremo più avanti.
10.5. Accesso concorrente alle risorse condivise
10.5.1. Accesso concorrente non sincronizzato
Nel paragrafo sullo scambio di informazioni tra thread, le informazioni scambiate riguardavano solo due thread e avvenivano in momenti ben precisi. Si trattava del classico passaggio di parametri. In altri casi, le informazioni sono condivise da diversi thread, che potrebbero volerle leggere o aggiornare contemporaneamente. Ciò solleva il problema dell'integrità delle informazioni. Supponiamo che le informazioni condivise siano una struttura S con vari elementi di informazione I1, I2, ... In.
- un thread T1 inizi ad aggiornare la struttura S: modifica il campo I1 e viene interrotto prima di completare l'intero aggiornamento della struttura S
- un thread T2 che recupera il processore legge quindi la struttura S per prendere decisioni. Legge una struttura in uno stato instabile: alcuni campi sono aggiornati, altri no.
Chiamiamo questa situazione "accesso a una risorsa condivisa", in questo caso la struttura S, e spesso è piuttosto complicata da gestire. Prendiamo il seguente esempio per illustrare i problemi che possono sorgere:
- un'applicazione genererà n thread, dove n viene passato come parametro
- la risorsa condivisa è un contatore che deve essere incrementato da ogni thread generato
- alla fine dell'applicazione, viene visualizzato il valore del contatore. Dobbiamo quindi trovare n.
Il programma è il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program {
// class variables
static int cptrThreads = 0; // thread counter
//hand
public static void Main(string[] args) {
// instructions for use
const string syntaxe = "pg nbThreads";
const int nbMaxThreads = 100;
// verification no. of arguments
if (args.Length != 1) {
// error
Console.WriteLine(syntaxe);
// stop
Environment.Exit(1);
}
// argument quality check
int nbThreads = 0;
bool erreur = false;
try {
nbThreads = int.Parse(args[0]);
if (nbThreads < 1 || nbThreads > nbMaxThreads)
erreur = true;
} catch {
// error
erreur = true;
}
// mistake?
if (erreur) {
// error
Console.Error.WriteLine("Nombre de threads incorrect (entre 1 et 100)");
// end
Environment.Exit(2);
}
// thread creation and generation
Thread[] threads = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creation
threads[i] = new Thread(Incrémente);
// naming
threads[i].Name = "" + i;
// launch
threads[i].Start();
}//for
// waiting for threads to finish
for (int i = 0; i < nbThreads; i++) {
threads[i].Join();
}
// counter display
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// increases thread counter
// meter reading
int valeur = cptrThreads;
// follow-up
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
// waiting
Thread.Sleep(1000);
// counter incrementation
cptrThreads = valeur + 1;
// follow-up
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, che è già stata trattata. Diamo invece un'occhiata all'incremento, 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 secondo. Perde quindi il processore
- riga 68: il contatore viene incrementato
Il passaggio 2 serve solo a costringere il thread a cedere il processore. Il processore verrà assegnato a un altro thread. In pratica, non vi è alcuna garanzia 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 l'illusione di una singola istruzione, c'è il rischio di perdere il processore tra la lettura del valore del contatore e la scrittura del suo valore incrementato di 1. Infatti, l'operazione di alto livello cptrThreads++ sarà oggetto di diverse istruzioni elementari a livello di processore. La fase di sospensione di un secondo del passo 2 serve quindi solo a sistematizzare questo rischio.
I risultati ottenuti con 5 thread sono i seguenti:
Leggendo questi risultati, è facile capire cosa sta succedendo:
- riga 1: un primo thread legge il contatore. Trova 0. Si ferma per 1 secondo e perde il processore
- riga 2: un secondo thread prende il controllo del processore e legge a sua volta il valore del contatore. È ancora a 0, poiché il thread precedente non lo ha ancora incrementato. Anche lui si ferma per 1 secondo e perde il processore.
- righe 1-5: in 1 s, tutti e 5 i thread hanno il tempo di passare e leggere il valore 0.
- righe 6-10: quando si riattivano uno dopo l'altro, incrementeranno il valore 0 che hanno letto e scriveranno il valore 1 sul contatore, come confermato dal programma principale (Main) alla riga 11.
Qual è il problema? Il secondo thread ha letto il valore sbagliato perché il primo era stato interrotto prima di completare il suo lavoro di aggiornamento del contatore nella finestra. Questo ci porta al concetto di risorse critiche e sezioni critiche 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 quest'ultimo accede a una risorsa critica. È necessario garantire che, durante tale sezione critica, il thread sia l'unico ad accedere alla risorsa.
Nel nostro esempio, la sezione critica è il codice compreso tra la lettura del contatore e la scrittura del suo nuovo valore:
// lecture compteur
int valeur = cptrThreads;
// attente
Thread.Sleep(1000);
// incrémentation compteur
cptrThreads = valeur + 1;
Per eseguire questo codice, è necessario garantire che un thread sia l'unico in esecuzione. Può essere interrotto, ma durante tale interruzione nessun altro thread deve poter eseguire lo stesso codice. La piattaforma .NET offre vari strumenti per garantire l'accesso unitario alle sezioni critiche del codice. Diamo un'occhiata ad alcuni di essi.
10.5.2. La clausola lock
La clausola lock viene utilizzata per definire una sezione critica come segue:
obj deve essere un riferimento a un oggetto visibile a tutti i thread che eseguono la sezione critica. Il blocco 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 {
// class variables
static int cptrThreads = 0; // thread counter
static object synchro = new object(); // synchronization object
//hand
public static void Main(string[] args) {
...
// waiting for threads to finish
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);
}
// counter display
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// increases thread counter
// exclusive access to the meter is required
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) {
// meter reading
int valeur = cptrThreads;
// follow-up
Console.WriteLine("A {0:hh:mm:ss}, le thread {1} a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
// waiting
Thread.Sleep(1000);
// counter incrementation
cptrThreads = valeur + 1;
// follow-up
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 sincronizza tutti i thread.
- righe 16-23: il metodo [Main] attende i thread in ordine inverso rispetto alla loro creazione.
- righe 29-40: la sezione critica del metodo Increment è stata delimitata dal lock.
I risultati ottenuti con 3 thread sono i seguenti:
- il thread 0 è il primo ad entrare nella sezione critica: righe 1, 2, 6, 8
- gli altri due thread rimarranno bloccati finché il thread 0 non uscirà dalla sezione critica: righe 3 e 4
- il thread 1 è il prossimo: righe 7, 9, 10
- il thread 2 è il successivo: righe 11, 12, 13
- riga 14: il thread Main, in attesa che il thread 2 finisca, riceve un avviso
- riga 15: il thread Main sta ora aspettando che il thread 1 finisca. Questo thread ha già finito. Il thread Main viene avvisato immediatamente, riga 16.
- righe 17-18: lo stesso processo si verifica con il thread 0
- riga 19: il numero di thread è corretto
10.5.3. La classe Mutex
Anche la classe System.Threading.Mutex può essere utilizzata per delimitare sezioni critiche. Si differenzia dal lock in termini di visibilità:
- la clausola lock sincronizza i thread nella stessa applicazione
- la classe Mutex consente di sincronizzare thread provenienti da applicazioni diverse.
Utilizzeremo il seguente costruttore e i seguenti metodi:
crea un Mutex M | |
Il thread T1 che esegue M.WaitOne() richiede la proprietà dell'oggetto di sincronizzazione M. Se il Mutex M non è detenuto da alcun thread (come all'inizio), viene "assegnato" al thread T1 che lo ha richiesto. Se, poco dopo, un thread T2 esegue la stessa operazione, verrà bloccato. Questo perché un Mutex può appartenere a un solo thread. Verrà sbloccato quando il thread T1 rilascerà il Mutex M che detiene. Diversi thread possono quindi essere bloccati in attesa del Mutex M. | |
Il thread T1 che esegue M.ReleaseMutex() rinuncia alla proprietà del mutex M. Quando il thread T1 perde il processore, il sistema può assegnarlo a uno dei thread in attesa del mutex M. Solo uno lo otterrà a turno, 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 rilascia con M.ReleaseMutex(). Una sezione critica di codice che deve essere eseguita da un solo thread alla volta è una risorsa condivisa. L'esecuzione della sezione critica può essere sincronizzata come segue:
dove M è un oggetto Mutex. Non dimenticare di liberare un Mutex che non serve più, in modo che un altro thread possa accedere alla sezione critica; altrimenti, i thread in attesa del Mutex non liberato non potranno mai accedere al processore.
Se applichiamo quanto appena visto all'esempio precedente, la nostra applicazione diventa la seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program3 {
// class variables
static int cptrThreads = 0; // thread counter
static Mutex synchro = new Mutex(); // synchronization object
//hand
public static void Main(string[] args) {
...
}
public static void Incrémente() {
....
synchro.WaitOne();
try {
...
} finally {
...
synchro.ReleaseMutex();
}
}
}
}
- riga 9: l'oggetto di sincronizzazione del thread è ora un Mutex.
- riga 18: inizio della sezione critica - solo un thread deve entrarvi. Blocchiamo fino a quando la sincronizzazione Mutex non è libera.
- riga 33: poiché un Mutex deve sempre essere rilasciato, indipendentemente dalla presenza di un'eccezione, gestiamo la sezione critica con un try / finally per liberare il Mutex nel finally.
- riga 23: il Mutex viene rilasciato una volta superata la sezione critica.
I risultati sono gli stessi di prima.
10.5.4. La classe AutoResetEvent
Un oggetto AutoResetEvent è una barriera che consente il passaggio di un solo thread alla volta, proprio come i due strumenti precedenti, lock* e Mutex. Si crea un oggetto AutoResetEvent* nel modo seguente:
Lo stato booleano indica se la barriera è chiusa (false) o aperta (true). Un thread che desidera superare la barriera lo indicherà come segue:
- se la barriera è aperta, il thread la attraversa e la barriera viene chiusa dietro di esso. Se erano in attesa più thread, possiamo essere certi che ne passerà solo uno.
- Se la barriera è chiusa, il thread viene bloccato. Un altro thread la aprirà al momento opportuno. Questo momento dipende interamente dal problema da risolvere. La barriera verrà aperta dall'operazione:
Può capitare che un thread voglia chiudere una barriera. Può farlo tramite:
Se, nell'esempio precedente, sostituiamo l'oggetto Mutex con un oggetto di tipo AutoResetEvent, il codice diventa:
using System;
using System.Threading;
namespace Chap8 {
class Program4 {
// class variables
static int cptrThreads = 0; // thread counter
static EventWaitHandle synchro = new AutoResetEvent(false); // synchronization object
//hand
public static void Main(string[] args) {
....
// we open the critical section barrier
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();
// waiting for threads to finish
...
// counter display
Console.WriteLine("Nombre de threads générés : " + cptrThreads);
}
public static void Incrémente() {
// increases thread counter
// exclusive access to the meter is required
...
synchro.WaitOne();
try {
...
} finally {
// release the resource
...
synchro.Set();
}
}
}
}
- riga 9: la barriera viene creata chiusa. Verrà aperta dal thread Main alla riga 16.
- riga 27: il thread responsabile dell'incremento del contatore dei thread richiede l'autorizzazione per entrare nella sezione critica. I vari thread si accumuleranno davanti alla barriera chiusa. Quando il Main la aprirà, uno dei thread in attesa passerà.
- riga 33: una volta terminato il proprio lavoro, riapre il cancello, consentendo l'ingresso a un altro thread.
I risultati sono simili a quelli precedenti.
10.5.5. La classe Interlocked
La classe Interlocked rende possibile l'atomicità di un gruppo di operazioni. All'interno di un gruppo di operazioni atomico, o tutte le operazioni vengono eseguite dal thread che esegue il gruppo, oppure nessuna. Non si rimane in uno stato in cui alcune operazioni sono state eseguite e altre no. Gli oggetti di sincronizzazione Lock, Mutex e AutoResetEvent sono tutti progettati per rendere atomico un gruppo di operazioni. Ciò si ottiene bloccando i thread. Interlocked consente di evitare il blocco dei thread per operazioni semplici ma frequenti. Interlocked offre i seguenti metodi statici:

Il metodo Incrementally ha la seguente firma:
Incrementa l'affitto. L'operazione è garantita atomica.
Il nostro programma di conteggio dei thread può quindi essere il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program5 {
// class variables
static int cptrThreads = 0; // thread counter
//hand
public static void Main(string[] args) {
...
}
public static void Incrémente() {
// increments the thread counter
Interlocked.Increment(ref cptrThreads);
}
}
}
- riga 17: il contatore dei thread viene incrementato in modo atomico.
10.6. Accesso in competizione a più risorse condivise
10.6.1. Un esempio
Negli esempi precedenti, una singola risorsa era condivisa dai diversi thread. La situazione può diventare più complicata se ci sono diverse risorse e queste dipendono l'una dall'altra. Ciò può portare a una situazione di interblocco. Questa situazione, nota anche come deadlock, si verifica quando due thread si aspettano a vicenda. Consideriamo le seguenti azioni che si susseguono nel tempo:
- un thread T1 ottiene la proprietà di un Mutex M1 per accedere a una risorsa condivisa R1
- un thread T2 ottiene 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. Viene bloccato.
In questo caso, i thread T1 e T2 si aspettano a vicenda. Questa situazione 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, utilizzando un unico mutex M. Tuttavia, ciò non è sempre possibile, ad esempio se comporta una mobilitazione dispendiosa in termini di tempo di una risorsa costosa. Un'altra soluzione consiste nel far sì che un thread che possiede M1 e non può ottenere M2, rilasci M1 per evitare l'interblocco.
- Abbiamo un array in cui alcuni thread depositano dati (scrittori) e altri li leggono (lettori).
- Gli scrittori sono uguali ma esclusivi: solo uno scrittore alla volta può inserire dati nella tabella.
- I lettori sono uguali ma esclusivi: solo un lettore alla volta può leggere i dati depositati nella tabella.
- Un lettore può leggere i dati nella tabella solo dopo che uno scrittore vi ha depositato dei dati, e uno scrittore può depositare nuovi dati nella tabella solo dopo che i dati in essa contenuti sono stati letti da un lettore.
Si possono distinguere due risorse condivise:
- la lavagna di scrittura: solo un autore alla volta può accedervi.
- la bacheca di sola lettura: solo un lettore alla volta può accedervi.
e un ordine di utilizzo di queste risorse:
- un lettore deve sempre seguire uno scrittore.
- uno scrittore deve sempre seguire un lettore, tranne la prima volta.
L'accesso a queste due risorse può essere controllato con due barriere di tipo AutoResetEvent :
- la barriera peutEcrire controllerà l'accesso degli scrittori alla scheda.
- la barriera peutLire controllerà l'accesso dei lettori alla scheda.
- La barriera peutEcrire verrà creata inizialmente aperta, consentendo il passaggio di un primo scrittore e bloccando tutti gli altri.
- La barriera peutLire verrà creata inizialmente chiusa, bloccando tutti i lettori.
- Quando uno scrittore ha terminato il suo lavoro, apre il cancello peutLire per far entrare un lettore.
- Quando un lettore ha terminato il suo lavoro, apre il cancello peutEcrire per far entrare uno scrittore.
Il programma che illustra questa sincronizzazione guidata dagli eventi è il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program {
// use of reader and writer threads
// illustrates the use of synchronization events
// class variables
static int[] data = new int[3 ]; // resource shared between reader and writer threads
static Random objRandom = new Random(DateTime.Now.Second ); // a random number generator
static AutoResetEvent peutLir e; // indicates that the contents of data can be read
static AutoResetEvent peutEcrir e; // indicates that you can write the contents of data
//hand
public static void Main(string[] args) {
// number of threads to generate
const int nbThreads = 2;
// flag initialization
peutLire = new AutoResetEvent(f als e); // cannot be read yet
peutEcrire = new AutoResetEvent( tru e); // we can already write
// creation of reader threads
Thread[] lecteurs = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creation
lecteurs[i] = new Thread(Lire);
lecteurs[i].Name = "L" + i.ToString();
// launch
lecteurs[i].Start();
}
// creating writer threads
Thread[] écrivains = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creation
écrivains[i] = new Thread(Ecrire);
écrivains[i].Name = "E" + i.ToString();
// launch
écrivains[i].Start();
}
//end of hand
Console.WriteLine("Fin de Main...");
}
// read the contents of the table
public static void Lire() {
...
}
// write in the table
public static void Ecrire() {
....
}
}
}
- riga 11: i dati della tabella sono la risorsa condivisa tra i thread di lettura e quelli di scrittura. Sono condivisi per la lettura dai thread di lettura e per la scrittura dai thread di scrittura.
- riga 13: l'oggetto peutLire viene utilizzato per segnalare ai thread di lettura che possono leggere i dati dell'array. Viene impostato su true dal thread di scrittura che ha compilato i dati della tabella. Viene inizializzato a false alla riga 23. Un thread di scrittura deve prima compilare l'array prima di passare l'evento peutLire a real.
- riga 14: l'oggetto peutEcrire viene utilizzato per avvisare i thread di scrittura che possono scrivere sui dati. Viene impostato su true dal thread di lettura che ha utilizzato tutti i dati dell'array. Viene inizializzato a true, riga 24. I dati della tabella sono liberi di essere scritti.
- righe 27-34: creazione e avvio dei thread di lettura
- righe 37-44: creazione e avvio dei thread di scrittura
Il metodo Read eseguito dai thread di lettura è il seguente:
public static void Lire() {
// follow-up
Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// we have to wait for reading authorization
peutLire.WaitOne();
// table reading
for (int i = 0; i < data.Length; i++) {
//wait 1 s
Thread.Sleep(1000);
// display
Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// we can write
peutEcrire.Set();
// follow-up
Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
- riga 5: attendiamo che un thread di scrittura segnali che l'array è stato riempito. Quando viene ricevuto questo segnale, solo uno dei thread di lettura in attesa di questo segnale può passare.
- righe 7-12: dati delle operazioni della tabella con un Sleep nel mezzo per costringere il thread a cedere il processore.
- riga 14: comunica ai thread di scrittura che l'array è stato letto e può essere riempito nuovamente.
Il metodo Write eseguito dai thread di scrittura è il seguente:
public static void Ecrire() {
// follow-up
Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// we have to wait for write authorization
peutEcrire.WaitOne();
// writing table
for (int i = 0; i < data.Length; i++) {
//wait 1 s
Thread.Sleep(1000);
// display
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]);
}
// on peut lire
peutLire.Set();
// follow-up
Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
- riga 5: attendiamo che un thread di lettura segnali che l'array è stato letto. Quando viene ricevuto questo segnale, solo uno dei thread di scrittura in attesa di questo segnale può passare.
- righe 7-13: dati delle operazioni sulla tabella con un Sleep nel mezzo per costringere il thread a cedere il processore.
- riga 15: comunica ai thread di lettura che l'array è stato riempito e può essere letto nuovamente.
L'esecuzione fornisce i seguenti risultati:
Vale la pena notare i seguenti punti:
- c'è solo 1 unità alla volta, anche se perde il processore nella sezione critica Read
- c'è un solo scrittore alla volta, anche se perde il processore nella sezione di revisione Write
- un lettore legge solo quando c'è qualcosa da leggere nella tabella
- uno scrittore non scrive finché l'immagine non è stata letta completamente
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 su peutEcrire.WaitOne, uno di essi, uno qualsiasi, viene sbloccato dall'operazione peutEcrire.Set. Se l'operazione precedente comporta l'apertura del gate a un particolare writer, le cose si complicano.
L'analogia è con un locale che serve il pubblico agli sportelli, dove ogni sportello è specializzato. Quando i clienti arrivano, prendono un biglietto dal distributore per lo sportello X e poi si siedono. Ogni biglietto è numerato e i clienti vengono chiamati in base al loro numero tramite un altoparlante. Durante l'attesa, i clienti possono fare ciò che vogliono. Possono leggere o appisolarsi. Ogni volta, viene svegliato dall'altoparlante che annuncia che il numero Y è stato chiamato allo sportello X. Se è lui, il cliente si alza e va allo sportello X, altrimenti continua a fare ciò che stava facendo.
Possiamo ragionare in modo simile. Prendiamo ad esempio gli scrittori:
le loro file sono bloccate | |
il thread che stava leggendo l'array comunica agli scrittori che l'array è disponibile. Esso o un altro thread imposta il thread dello scrittore in modo che superi la barriera. | |
ogni thread controlla se è quello scelto. Se lo è, supera la barriera. In caso contrario, torna in attesa. |
La classe Monitor viene utilizzata per implementare questo scenario.

Descriviamo ora una costruzione standard (pattern), proposta nel capitolo Threading del libro C# 3.0 citato nell'introduzione a questo documento, in grado di risolvere i problemi di barriera con condizioni di ingresso.
- Innanzitutto, i thread che condividono una risorsa (il contatore, ecc.) vi accedono tramite un oggetto che chiameremo token. Per aprire il cancello che conduce al contatore, è necessario disporre del token per aprirlo, e c'è un solo token. I thread devono quindi passarsi il token tra loro.
- Per raggiungere il contatore, i thread richiedono prima il :
Se il token è libero, viene assegnato al thread che ha eseguito l'operazione precedente, altrimenti il thread viene messo in attesa del token.
- Se l'accesso al contatore è non ordinato, c.a.d. se non importa chi entra, l'operazione precedente è sufficiente. Il thread con il token va al contatore. Se l'accesso è ordinato, il thread con il token verifica di soddisfare la condizione per andare al contatore:
Se il thread non è quello atteso allo sportello, rinuncia al proprio turno restituendo il token. Entra in uno stato di blocco. Verrà riattivato non appena il token sarà nuovamente disponibile. Verificherà quindi 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 possiede il token. In caso contrario, viene generata un'eccezione.
- Il thread che verifica la condizione per recarsi al contatore vi si reca:
- // lavoro al contatore
- ....
Prima di lasciare il contatore, il thread deve restituire il proprio token, altrimenti i thread bloccati in attesa di esso rimarranno bloccati a tempo indeterminato. Esistono due diverse situazioni:
- la prima situazione è quella in cui il thread che detiene il token è anche quello che segnala ai thread in attesa del token che esso è libero. Lo farà come segue:
Alla riga 6, si riattivano i thread in attesa del token. Ciò significa che diventano idonei a ricevere il token. Non significa che lo ricevano immediatamente. Alla riga 8, il token viene rilasciato. Tutti i thread idonei riceveranno il token a turno, in modo indeterminato. Questo darà loro l'opportunità di verificare nuovamente se soddisfano la condizione di accesso. Il thread che ha rilasciato il token ha modificato questa condizione alla riga 4 per consentire l'ingresso di un nuovo thread. Il primo thread a verificare questa condizione mantiene il token e passa al contatore a turno.
- La seconda situazione è quella in cui il thread che detiene il token non è quello che segnala ai thread in attesa del token che esso è libero. Deve tuttavia rilasciarlo, poiché il thread responsabile dell'invio di questo segnale deve essere il detentore del token. Lo farà utilizzando l'operazione:
Il token è ora disponibile, ma i thread in attesa (che hanno eseguito un'operazione Wait(token)) non vengono avvisati. Questo compito è affidato a un altro thread, che a un certo punto eseguirà un codice simile al seguente:
Alla fine, la struttura standard proposta nel capitolo Threading del libro C# 3.0 è la seguente:
- define counter access token :
- richiedi l'accesso al contatore:
lock(jeton){
while (! jeNeSuisPasCeluiQuiEstAttendu)
Monitor.Wait(jeton);
}
// passage au guichet
...
è equivalente a
Si noti che in questo schema il token viene rilasciato immediatamente, non appena viene superata la barriera. Un altro thread può quindi verificare la condizione di accesso. La costruzione precedente consente quindi a tutti i thread di verificare la condizione di accesso. Se questo non è ciò che si desidera, è possibile scrivere:
lock(jeton){
while (! jeNeSuisPasCeluiQuiEstAttendu)
Monitor.Wait(jeton);
// passage au guichet
...
}
dove il gettone viene rilasciato solo dopo il passaggio allo sportello.
- modificare le condizioni di accesso al contatore e avvisare gli altri thread
lock(jeton){
// modifier la condition d'accès au guichet
...
// en avertir les threads en attente du jeton
Monitor.PulseAll(jeton);
}
Nell'esempio sopra, la condizione di accesso può essere modificata solo dal thread che detiene il token. È anche possibile scrivere:
// modifier la condition d'accès au guichet
...
// en avertir les threads en attente du jeton
Monitor.PulseAll(jeton);
// libérer le jeton
Monitor.Exit(jeton);
se il thread possiede già il token.
Grazie a queste informazioni, possiamo riscrivere l'applicazione lettori/scrittori, impostando un ordine con cui lettori e scrittori possono accedere ai rispettivi contatori. Il codice è il seguente:
using System;
using System.Threading;
namespace Chap8 {
class Program2 {
// use of reader and writer threads
// illustrates the use of synchronization events
// class variables
static int[] data = new int[3 ]; // resource shared between reader and writer threads
static Random objRandom = new Random(DateTime.Now.Second ); // a random number generator
static object peutLire = new object( ); // indicates that the contents of data can be read
static object peutEcrire = new object( ); // indicates that you can write the contents of data
static bool lectureAutorisée = fals e; // to authorize the reading of the table
static bool écritureAutorisée = fals e; // to authorize writing in the table
static string[] ordreLectur e; // sets the order of readers
static string[] ordreEcritur e; // sets the order for writers
static int lecteurSuivant = 0; // indicates the next drive number
static int écrivainSuivant = 0; // indicates the number of the following writer
//hand
public static void Main(string[] args) {
// number of threads to generate
const int nbThreads = 5;
// creation of reader threads
Thread[] lecteurs = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creation
lecteurs[i] = new Thread(Lire);
lecteurs[i].Name = "L" + i.ToString();
// launch
lecteurs[i].Start();
}
// create playback order
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);
}
// creating writer threads
Thread[] écrivains = new Thread[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creation
écrivains[i] = new Thread(Ecrire);
écrivains[i].Name = "E" + i.ToString();
// launch
écrivains[i].Start();
}
// creation of writing order
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);
}
// write authorization
lock (peutEcrire) {
écritureAutorisée = true;
Monitor.Pulse(peutEcrire);
}
//end of hand
Console.WriteLine("Fin de Main...");
}
// read the contents of the table
public static void Lire() {
...
}
// write in the table
public static void Ecrire() {
...
}
}
}
L'accesso al banco di lettura è soggetto alle seguenti condizioni:
- riga 13: il token peutLire
- riga 15: il valore booleano readingAuthorized
- riga 17: la tabella ordinata dei lettori. I lettori si recano allo sportello di lettura nell'ordine di questa tabella, che contiene i loro nomi.
- riga 19: lecteurSuivant indica il numero del prossimo lettore autorizzato a recarsi allo sportello.
L'accesso allo sportello è soggetto alle seguenti condizioni:
- riga 14: il token peutEcrire
- riga 16: il valore booleano writingAuthorized
- riga 18: la tabella ordinata degli scrittori. Gli scrittori si recano alla scrivania nell'ordine di questa tabella contenente i loro nomi.
- riga 20: writerNext indica il numero del prossimo scrittore autorizzato ad accedere al contatore.
Gli altri elementi del codice sono i seguenti:
- righe 29-36: creano e avviano i thread di lettura. Saranno tutti bloccati perché la lettura non è autorizzata (riga 15).
- righe 39-43: il loro ordine di passaggio attraverso il contatore sarà in ordine inverso rispetto alla loro creazione.
- righe 46-53: creano e lanciano thread di scrittura. Saranno tutti bloccati perché la scrittura non è consentita (riga 16).
- righe 56-60: il loro ordine di passaggio attraverso il contatore sarà nell'ordine della loro creazione.
- riga 64: la scrittura è autorizzata
- riga 65: gli scrittori vengono avvisati che qualcosa è cambiato.
Il metodo Read è il seguente:
public static void Lire() {
// follow-up
Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// we have to wait for reading authorization
lock (peutLire) {
while (!lectureAutorisée || ordreLecture[lecteurSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutLire);
}
// table reading
for (int i = 0; i < data.Length; i++) {
//wait 1 s
Thread.Sleep(1000);
// display
Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
}
// next reader
lectureAutorisée = false;
lecteurSuivant++;
// writers are warned that they can write
lock (peutEcrire) {
écritureAutorisée = true;
Monitor.PulseAll(peutEcrire);
}
// follow-up
Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
}
- tutto l'accesso allo sportello è controllato dal blocco righe 5-27. Il lettore che preleva il gettone lo conserva per tutta la durata della sua permanenza allo sportello
- righe 6-8: un lettore che ha acquisito il token alla riga 5 lo rilascia se la lettura non è autorizzata o se non è il suo turno di passare.
- righe 10-15: passaggio al contatore (operazione tabella)
- righe 17-18: il thread modifica le condizioni di accesso allo sportello di lettura. Si noti che possiede ancora il gettone di lettura e che queste modifiche non consentono ancora a un lettore di passare.
- righe 20-23: il thread modifica le condizioni di accesso al banco di scrittura e avvisa tutti gli scrittori in attesa che qualcosa è cambiato.
- riga 27: il blocco 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 booleano readingAuthorized è falso. Inoltre, tutti i thread in attesa di peutLire rimangono tali, poiché PulseAll(peutLire) non ha ancora avuto luogo.
Il metodo Write è il seguente:
public static void Ecrire() {
// follow-up
Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
// we have to wait for write authorization
lock (peutEcrire) {
while (!écritureAutorisée || ordreEcriture[écrivainSuivant] != Thread.CurrentThread.Name) {
Monitor.Wait(peutEcrire);
}
// writing table
for (int i = 0; i < data.Length; i++) {
//wait 1 s
Thread.Sleep(1000);
// display
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]);
}
// next writer
écritureAutorisée = false;
écrivainSuivant++;
// readers waiting for the peutLire token are woken up
lock (peutLire) {
lectureAutorisée = true;
Monitor.PulseAll(peutLire);
}
// follow-up
Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
}
}
- tutto l'accesso alla scrivania è controllato dal blocco righe 5-27. Lo scrittore che raccoglie il gettone lo conserva per tutta la durata della sua permanenza allo sportello
- righe 6-8: uno scrittore che ha acquisito il token alla riga 5 lo rilascia se la scrittura non è autorizzata o se non è il suo turno di passare.
- righe 10-16: passaggio al contatore (operazione sulla tabella)
- righe 18-19: il thread modifica le condizioni di accesso alla scrivania. 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 al banco di lettura e avvisa tutti i lettori in attesa che qualcosa è cambiato.
- riga 27: il blocco 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 booleano writingAuthorized è falso. Inoltre, tutti i thread in attesa del peutEcrire rimangono tali in attesa di una nuova operazione PulseAll(peutEcrire).
Un esempio di esecuzione è il seguente:
10.7. Pool di thread
Finora, per gestire :
- li creavamo con Thread T=new Thread(...)
- e poi li eseguivamo con T.Start()
Abbiamo visto nel capitolo "Database" che con alcuni SGBD era possibile avere pool di connessioni aperte:
- n connessioni vengono aperte all'avvio del pool
- quando un thread richiede una connessione, gli viene assegnata una delle connessioni aperte nel pool
- quando il thread chiude la connessione, questa non viene chiusa ma restituita al pool
L'uso di un pool di connessioni è trasparente dal punto di vista del codice. Il vantaggio risiede nel miglioramento delle prestazioni: aprire una connessione è costoso. Qui 10 connessioni aperte possono servire centinaia di richieste.
Esiste un sistema simile per i thread:
- al momento dell'avvio del pool vengono creati un numero minimo di thread. Il valore di min viene impostato utilizzando ThreadPool.SetMinThreads(min1,min2). Un pool di thread può essere utilizzato per eseguire attività asincrone di tipo blocking o non-blocking. Il primo parametro min1 imposta il numero di thread blocking, il secondo min2 il numero di thread asincroni. I valori correnti di queste due variabili possono essere ottenuti tramite ThreadPool.GetMinThreads(out min1,out min2).
- Se questo numero non è sufficiente, il pool creerà altri thread per rispondere alle richieste fino al limite di max threads. Il valore di max viene impostato utilizzando ThreadPool.SetMaxThreads(max1,max2). Entrambi i parametri hanno lo stesso significato di quelli in SetMinThreads. I valori attuali di questi due valori possono essere ottenuti tramite ThreadPool.GetMaxThreads(out max1,out max2). Quando i thread max1 sono stati raggiunti, le richieste di thread per le attività di blocco verranno messe in coda in attesa di un thread libero nel pool.
Un pool di thread offre una serie di vantaggi:
- come con 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: impostando un numero massimo di thread, si evita di sovraccaricare l'applicazione con troppe richieste. Queste verranno inserite in una coda di file.
Per assegnare un'attività a un thread nel pool, utilizzare uno dei due metodi:
- ThreadPool.QueueWorkItem(WaitCallBack)
- ThreadPool.QueueWorkItem(WaitCallBack,object)
dove WaitCallBack è un metodo qualsiasi con la firma void WaitCallBack(object). Il metodo 1 chiede a un thread di eseguire il metodo WaitCallBack senza passare alcun parametro. Il metodo 2 fa la stessa cosa, ma passa un parametro di tipo object a WaitCallBack.
Il programma seguente illustra questi concetti:
using System;
using System.Threading;
namespace Chap8 {
class Program {
public static void Main() {
// init Current thread
Thread main = Thread.CurrentThread;
// name the Thread
main.Name = "Main";
// we use a thread pool
int min1, min2;
// set the minimum number of blocking threads
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);
// set the maximum number of blocking threads
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);
// 7 threads are executed
for (int i = 0; i < 7; i++) {
// start execution of thread i in a pool
ThreadPool.QueueUserWorkItem(Sleep, new Data2 { Numéro = i.ToString(), Début = DateTime.Now, Durée = i + 10 });
}
// end of hand
Console.Write("Tapez [entrée] pour terminer le thread {0} à {1:hh:mm:ss:FF}", main.Name, DateTime.Now);
// waiting
Console.ReadLine();
}
public static void Sleep(object infos) {
// parameter is retrieved
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);
// pool status
int cpt1, cpt2;
ThreadPool.GetAvailableThreads(out cpt1, out cpt2);
Console.WriteLine("Nombre de threads pour tâches bloquantes disponibles dans le pool : {0}", cpt1);
// sleep mode for Duration
Thread.Sleep(data.Durée * 1000);
// end of execution
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 {
// miscellaneous information
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: viene richiesto e visualizzato il numero minimo attuale di thread nel pool di thread
- riga 18: modifica il numero minimo di thread per le attività di blocco a 2
- righe 19-21: vengono visualizzati i nuovi valori minimi
- righe 22-28: si procede allo stesso modo per impostare il numero massimo di thread per le attività di blocco: 5
- righe 30-33: vengono eseguiti 7 task in un pool di 5 thread. 5 task dovrebbero ottenere 1 thread, i primi 2 rapidamente poiché 2 thread sono sempre presenti, gli altri 3 con un tempo di attesa di 0,5 secondi. 2 task dovrebbero attendere che un thread diventi disponibile.
- riga 32: le attività eseguono Sleep nelle righe 40-54 passandole un parametro di tipo Data2 definito nelle righe 56-62.
- riga 40: il metodo Sleep eseguito dai task
- riga 42: recupera il parametro passato a Sleep.
- riga 43: il task si identifica sulla console
- righe 45-47: visualizza il numero di thread attualmente disponibili. Vogliamo vedere come si evolve.
- riga 49: il task si ferma per alcuni secondi (task di blocco).
- riga 52: quando si riattiva, visualizziamo alcune informazioni sul suo account.
I risultati sono i seguenti.
Per i numeri min e max dei thread nel pool:
Per eseguire i 7 thread:
- righe 1-6: le prime 3 attività vengono eseguite in sequenza. Trovano immediatamente 1 thread disponibile (MinThreads=3) e poi entrano in stato di sospensione.
- righe 7-9: per le attività 3 e 4, il tempo è leggermente più lungo. Per ciascuna di esse non c'era alcun thread libero. Abbiamo dovuto 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 suo thread.
- righe 13-14: l'attività 1 termina. L'attività 6 prende il suo thread.
- righe 17-21: le attività vengono completate una dopo l'altra.
10.8. La classe BackgroundWorker
10.8.1. Esempio 1
La classe BackgroundWorker appartiene allo spazio dei nomi [System.ComponentModel]. Viene utilizzata allo stesso modo di un thread, ma presenta alcune caratteristiche speciali che in certi casi possono renderla più interessante della classe [Thread]:
- emette i seguenti eventi:
- DoWork : un thread ha richiesto l'esecuzione di BackgroundWorker
- ProgressChanged : l'oggetto BackgroundWorker ha eseguito ReportProgress. Viene utilizzato per fornire una percentuale di completamento.
- RunWorkerCompleted: l'oggetto BackgroundWorker ha completato il proprio lavoro. Potrebbe averlo completato normalmente, oppure con una cancellazione o un'eccezione.
Questi eventi rendono il BackgroundWorker utile nelle interfacce grafiche: un'operazione che richiede molto tempo verrà affidata a un BackgroundWorker che sarà in grado di segnalare i propri progressi tramite l'evento ProgressChanged e il proprio completamento tramite l'evento RunWorkerCompleted. Il lavoro che deve essere svolto dal BackgroundWorker verrà eseguito da un metodo associato a DoWork.
- È possibile richiederne l'annullamento. In un'interfaccia grafica, un'operazione lunga può essere annullata dall'utente.
- Gli oggetti BackgroundWorker appartengono a un pool e vengono riciclati secondo necessità. Un'applicazione che necessita di un BackgroundWorker lo otterrà dal pool, che le fornirà un thread esistente ma inutilizzato. Riciclare i thread in questo modo, anziché crearne uno nuovo ogni volta, migliora le prestazioni.
Utilizziamo questo strumento nell'applicazione precedente quando l'accesso al contatore non è controllato:
using System;
using System.Threading;
using System.ComponentModel;
namespace Chap8 {
class Program2 {
// use of reader and writer threads
// illustrates the simultaneous use of shared resources and synchronization
// class variables
const int nbThreads = 2; // total number of threads
static int nbLecteursTerminés = 0; // number of terminated threads
static int[] data = new int[5]; // shared array between reader and writer threads
static object appli; // synchronizes access to number of completed threads
static Random objRandom = new Random(DateTime.Now.Second); // a random number generator
static AutoResetEvent peutLire; // indicates that the contents of the table can be read
static AutoResetEvent peutEcrire; // points out that we can write in the table
static AutoResetEvent finLecteurs; // signals the end of readers
//hand
public static void Main(string[] args) {
// give the thread a name
Thread.CurrentThread.Name = "Main";
// flag initialization
peutLire = new AutoResetEvent(fals e); // cannot be read yet
peutEcrire = new AutoResetEvent(tru e); // we can already write
finLecteurs = new AutoResetEvent(false); // application not completed
// synchronizes access to terminated thread counter
appli = new object();
// creation of reader threads
MyBackgroundWorker[] lecteurs = new MyBackgroundWorker[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creation
lecteurs[i] = new MyBackgroundWorker();
lecteurs[i].Numéro = "L" + i;
lecteurs[i].DoWork += Lire;
lecteurs[i].RunWorkerCompleted += EndLecteur;
// launch
lecteurs[i].RunWorkerAsync();
}
// creating writer threads
MyBackgroundWorker[] écrivains = new MyBackgroundWorker[nbThreads];
for (int i = 0; i < nbThreads; i++) {
// creation
écrivains[i] = new MyBackgroundWorker();
écrivains[i].Numéro = "E" + i;
écrivains[i].DoWork += Ecrire;
// launch
écrivains[i].RunWorkerAsync();
}
// wait for all threads to finish
finLecteurs.WaitOne();
//end of hand
Console.WriteLine("Fin de Main...");
}
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
...
}
// read the contents of the table
public static void Lire(object sender, DoWorkEventArgs infos) {
...
}
// write in the table
public static void Ecrire(object sender, DoWorkEventArgs infos) {
...
}
}
// thread
internal class MyBackgroundWorker : BackgroundWorker {
// miscellaneous information
public string Numéro { get; set; }
}
}
Elenchiamo solo le modifiche:
- la classe Thread è stata sostituita da MyBackgroundWorker alle righe 79-82. Il metodo della classe BackgroundWorker è stato modificato per assegnare un numero al thread. Avremmo potuto procedere diversamente passando un oggetto a 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 loro lavoro. A tal fine, alla riga 12, il contatore nbReadersTerminated conta il numero di thread di lettura che hanno completato il loro lavoro. Questo contatore viene incrementato da EndLecteur alle righe 63-65, che viene eseguito ogni volta che un thread di lettura termina. È questa procedura che controlla l'AutoResetEvent finLecteurs alla riga 18, che è sincronizzato alla riga 59 con Hand.
- riga 16: poiché diversi thread di lettura potrebbero voler incrementare contemporaneamente il contatore nbReadersTerminated, l'accesso esclusivo ad esso è garantito dall'oggetto di sincronizzazione app. Questo 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 No
- riga 40: viene assegnata l'operazione di lettura 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 No
- riga 52: gli viene assegnata l'operazione di scrittura da eseguire
- riga 54: il thread viene avviato
I metodi Read e Write rimangono invariati. Il metodo EndLecteur viene eseguito alla fine di ogni thread di lettura. Il suo codice è il seguente:
public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
// increment no. of completed drives
lock (appli) {
nbLecteursTerminés++;
if (nbLecteursTerminés == nbThreads)
finLecteurs.Set();
}
}
Il ruolo del metodo EndLecteur è quello di notificare al Main che tutti i lettori hanno completato il loro lavoro.
- riga 4: il contatore nbReadersTerminated viene incrementato.
- righe 5-6: se tutti i lettori hanno completato il loro lavoro, l'evento finLecteurs viene impostato su true per impedire al Main di attendere questo evento.
- Poiché EndLecteur viene eseguito da più thread, la sezione critica precedente è protetta dal blocco alla riga 3.
L'esecuzione fornisce risultati simili a quelli della versione multithread.
10.8.2. Esempio 2
Il codice seguente illustra altri aspetti della classe BackgroundWorker:
- la possibilità di annullare l'attività
- viene segnalata un'eccezione generata nell'attività
- passaggio di un parametro I/O all'attività
using System;
using System.Threading;
using System.ComponentModel;
namespace Chap8 {
class Program3 {
// threads
static BackgroundWorker[] tâches = new BackgroundWorker[5];
public static void Main() {
// init Current thread
Thread main = Thread.CurrentThread;
// name the Thread
main.Name = "Main";
// thread creation
for (int i = 0; i < tâches.Length; i++) {
// create thread n° i
tâches[i] = new BackgroundWorker();
// initialize it
tâches[i].DoWork += Sleep;
tâches[i].RunWorkerCompleted += End;
tâches[i].WorkerSupportsCancellation = true;
// launch it
tâches[i].RunWorkerAsync(new Data { Numéro = i, Début = DateTime.Now, Durée = i + 1 });
}
// cancel the last thread
tâches[4].CancelAsync();
// end of hand
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 {
// miscellaneous information
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: il BackgroundWorker
- righe 18-27: creazione del thread
- riga 20: creazione del thread
- riga 22: il thread eseguirà le righe 39-41 di Sleep
- riga 23: il metodo End nelle righe 43-45 verrà eseguito alla fine del thread
- riga 24: il thread può 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:
- Numero (input): numero del thread
- Start (ingresso): ora di inizio del thread
- Durata (input): durata dello Sleep
- Fine (uscita): fine dell'esecuzione del thread
- riga 29: il thread n. 4 viene annullato
Tutti i thread eseguono lo Sleep successivo:
public static void Sleep(object sender, DoWorkEventArgs infos) {
// we use the info parameter
Data data = (Data)infos.Argument;
// exception for task no. 3
if (data.Numéro == 3) {
throw new Exception("test....");
}
// sleep mode for Duration, stopping every second
for (int i = 1; i <= data.Durée && !tâches[data.Numéro].CancellationPending; i++) {
// wait 1 second
Thread.Sleep(1000);
}
// end of execution
data.Fin = DateTime.Now;
// initialize the result
infos.Result = data;
infos.Cancel = tâches[data.Numéro].CancellationPending;
}
- riga 1: il metodo Sleep ha la firma standard di un gestore di eventi. Riceve due parametri:
- sender : il mittente dell'evento, in questo caso il BackgroundWorker che esegue il
- news : di tipo DoWorkEventArgs che fornisce informazioni sull'evento DoWork. Questo parametro viene utilizzato sia per trasmettere informazioni al thread che per recuperarne i risultati.
- riga 3: il parametro passato a RunWorkerAsync del task si trova in infos.Argument.
- righe 5-7: viene generata un'eccezione per il task n. 3
- righe 9-12: il thread "dorme" per Duration secondi con incrementi di un secondo per consentire il test di annullamento alla riga 9. Questo simula un'operazione di lunga durata durante la quale il thread controllerebbe regolarmente la presenza di una richiesta di annullamento. Per indicare che è stato annullato, il thread deve impostare la proprietà infos.Cancel su true (riga 17).
- riga 16: il thread può restituire un risultato al thread che lo ha avviato. Inserisce questo risultato in infos.Result.
Una volta completato, i thread eseguono il comando End next :
public static void End(object sender, RunWorkerCompletedEventArgs infos) {
// the infos parameter is used to display the result of execution
// exception?
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 di un gestore di eventi. Riceve due parametri:
- sender : il mittente dell'evento, in questo caso il BackgroundWorker che esegue il
- news : 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 cancellazione, allora infos.Result è il risultato del thread eseguito. L'utilizzo di questo risultato nel caso in cui il thread sia stato cancellato o abbia generato un'eccezione provoca un'eccezione. Pertanto, alle righe 5 e 13, non siamo in grado di visualizzare il numero del thread che è stato annullato o che ha generato un'eccezione, poiché questo numero si trova in infos.Result. Questo problema può essere aggirato derivando la classe BackgroundWorker per memorizzare le informazioni da scambiare tra il thread chiamante e il thread chiamato, come nell'esempio precedente. Utilizziamo quindi l'argomento sender che rappresenta il BackgroundWorker al posto di news.
I risultati sono i seguenti:
10.9. Dati locali al 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 dedicato. Il ciclo di vita del thread è il seguente:
- il thread viene creato o richiesto da un pool di thread per soddisfare una richiesta dell'utente
- se questa richiesta richiede dati, il thread eseguirà un metodo dal livello [ui], che chiamerà un metodo dal livello [metier], il quale a sua volta chiamerà un metodo dal livello [dao].
- il thread restituisce la risposta all'utente. Quindi scompare o viene riciclato in un pool di thread.
Nell'operazione 2, potrebbe essere interessante che il thread disponga di dati propri, ovvero non condivisi con altri thread. Questi dati potrebbero, ad esempio, appartenere all'utente specifico a cui il thread sta fornendo il servizio. Tali dati potrebbero quindi essere utilizzati nei vari livelli [ui, metier, dao].
La classe Thread rende possibile 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 il nome della chiave. | |
associa il valore data al nome della chiave dal dizionario privato del thread | |
recupera il valore associato al nome dal dizionario privato del thread |
Un modello di utilizzo potrebbe essere il seguente:
- per creare una coppia (chiave, valore) associata al thread corrente:
- per recuperare il valore associato alla chiave:
10.9.2. Applicazione del principio
Si consideri 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 elemento nel database
- riga 6: per recuperare tutti gli articoli nel database
- riga 7: per eliminare tutti gli articoli dal database
In seguito, avremo bisogno di un metodo per inserire un array di articoli utilizzando una transazione, poiché vogliamo operare in modalità "tutto o niente": o vengono inseriti tutti gli articoli, oppure nessuno. Possiamo quindi modificare l'interfaccia per integrare questo nuovo requisito:
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 array di articoli al database
In seguito, per un'altra applicazione, si presenta la necessità di eliminare un elenco di articoli salvati in una lista, sempre all'interno di una transazione. Come possiamo vedere, il livello [dao] si amplierà per soddisfare diverse esigenze aziendali. Possiamo seguire un altro percorso:
- inserire nel livello [dao] solo le operazioni di base InsertArticle, DeleteArticle, UpdateArticle, SelectArticle, SelectArticles
- trasferire al livello [business] l'aggiornamento simultaneo di più articoli. Questi utilizzerebbero le operazioni elementari del livello [dao].
Il vantaggio di questa soluzione è che lo stesso livello [dao] può essere utilizzato senza modifiche con diversi livelli [metier]. Tuttavia, ciò introduce una difficoltà nella gestione della transazione, che raggruppa gli aggiornamenti da effettuare in modo atomico:
- 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, se presente
- la transazione deve essere terminata dal livello [business].
Per garantire che i metodi del livello [dao] siano a conoscenza dell'esistenza di qualsiasi transazione in corso, potremmo aggiungere la transazione come parametro a ciascun metodo del livello [dao]. Questo parametro apparirà quindi nella firma dei metodi dell'interfaccia, collegandola a una specifica fonte di dati: il database. I dati locali del thread ci forniscono una soluzione più elegante: il livello [business] inserirà la transazione nei dati locali del thread e il livello [dao] la recupererà da lì. Non è necessario modificare la firma del metodo del livello [dao].
Stiamo implementando questa soluzione con il seguente progetto Visual Studio:
![]() |
![]() |
- in [1]: la soluzione nel suo complesso
- in [2]: i riferimenti utilizzati. Poiché [4] è un database SQL Server Compact, è necessario il riferimento [System.Data.SqlServerCe].
- in [3]: i diversi livelli dell'applicazione.
La base [4] è il database SQL Server Compact già utilizzato nel capitolo precedente, in particolare nel paragrafo 9.3.1.
![]() |
La classe Article
Una riga della tabella precedente [articles] è incapsulata in un oggetto di tipo Article:
namespace Chap8 {
public class Article {
// properties
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; }
// manufacturers
public Article() {
}
public Article(int id, string nom, decimal prix, int stockActuel, int stockMinimum) {
Id = id;
Nom = nom;
Prix = prix;
StockActuel = stockActuel;
StockMinimum = stockMinimum;
}
// identity
public override string ToString() {
return string.Format("[{0},{1},{2},{3},{4}]", Id, Nom, Prix, StockActuel, StockMinimum);
}
}
}
Interfaccia Layer [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 elemento nella tabella [articles]
- riga 6: per inserire tutte le righe della tabella [articles] in un oggetto di tipo Article
- riga 7: per cancellare tutte le righe della tabella [articoli]
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: lo stesso ma senza transazione
- riga 7: per ottenere un elenco di tutti gli articoli
- riga 8: per cancellare tutti gli articoli
Implementazione del livello [metier]
L'implementazione dell'interfaccia Trade IMetier sarà la seguente:
using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;
namespace Chap8 {
public class Metier : IMetier {
// layer [dao]
public IDao Dao { get; set; }
// connecting chain
public string ConnectionString { get; set; }
// insert an array of articles inside a transaction
public void InsertArticlesInTransaction(Article[] articles) {
// create the connection to the
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// opening connection
connexion.Open();
// transaction
SqlCeTransaction transaction = null;
try {
// start of transaction
transaction = connexion.BeginTransaction(IsolationLevel.ReadCommitted);
// register the transaction in the thread
Thread.SetData(Thread.GetNamedDataSlot("transaction"), transaction);
// articles insertion
foreach (Article article in articles) {
Dao.InsertArticle(article);
}
// validate the transaction
transaction.Commit();
} catch {
// we undo the transaction
if (transaction != null)
transaction.Rollback();
}
}
}
// insertion of an array of articles without transaction
public void InsertArticlesOutOfTransaction(Article[] articles) {
// articles insertion
foreach (Article article in articles) {
Dao.InsertArticle(article);
}
}
// articles list
public List<Article> GetAllArticles() {
return Dao.GetAllArticles();
}
// delete all articles
public void DeleteAllArticles() {
Dao.DeleteAllArticles();
}
}
}
La classe ha le seguenti proprietà:
- riga 9: un riferimento al livello [dao]
- riga 11: la stringa di connessione utilizzata per connettersi al database degli articoli
Commentiamo solo il metodo InsertArticlesInTransaction, che da solo presenta delle difficoltà:
- riga 16: viene creata una connessione al database
- riga 18: ora si apre
- riga 23: viene creata una transazione
- riga 25: salvata nei dati locali del thread, associata alla chiave "transaction"
- righe 27-29: viene chiamato il metodo di inserimento dell'unità del livello [dao] per ogni elemento da inserire
- righe 21 e 32: l'intero inserimento dell'array è controllato da un try / catch
- riga 31: se si raggiunge questo punto, non si è verificata alcuna eccezione. La transazione viene quindi convalidata.
- righe 34-35: si è verificata un'eccezione, la transazione viene annullata
- riga 37: uscita dalla clausola using. La connessione aperta alla riga 18 viene chiusa automaticamente.
Implementazione del livello [dao]
L'implementazione dell'interfaccia Dao IDao sarà la seguente:
using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;
namespace Chap8 {
public class Dao : IDao {
// connecting chain
public string ConnectionString { get; set; }
// requests
public string InsertText { get; set; }
public string DeleteAllText { get; set; }
public string GetAllText { get; set; }
// interface implementation
// article insertion
public int InsertArticle(Article article) {
// is there a transaction in progress?
SqlCeTransaction transaction = Thread.GetData(Thread.GetNamedDataSlot("transaction")) as SqlCeTransaction;
// retrieve or create connection
SqlCeConnection connexion = null;
if (transaction != null) {
// recover connection
connexion = transaction.Connection as SqlCeConnection;
} else {
// create it
connexion = new SqlCeConnection(ConnectionString);
connexion.Open();
}
try {
// preparation of insertion order
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;
// execution
return sqlCommand.ExecuteNonQuery();
} finally {
// if you were not in a transaction, you close the connection
if (transaction == null) {
connexion.Close();
}
}
}
// articles list
public List<Article> GetAllArticles() {
...
}
// deletion of articles
public void DeleteAllArticles() {
...
}
}
}
La classe ha le seguenti proprietà:
- riga 9: la stringa di connessione utilizzata per connettersi al database degli articoli
- riga 11: comando SQL per inserire un articolo
- riga 12: comando SQL per eliminare tutti gli articoli
- riga 13: 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: recupera eventuali transazioni inserite dal livello [metier] nel thread
- righe 23-25: se la transazione è presente, viene recuperata la connessione a cui era collegata.
- righe 26-30: in caso contrario, viene creata e aperta una nuova connessione.
- righe 33-44: prepara il comando di inserimento. Questo è parametrizzato (vedi riga g di App.config).
- riga 33: viene creato l'oggetto Command.
- riga 34: viene associato alla transazione corrente. Se la transazione corrente non esiste (transaction=null), ciò equivale a eseguire il comando SQL senza una transazione esplicita. In questo caso, esiste comunque una transazione implicita. Con SQL Server CE, questa transazione implicita è impostata di default in modalità autocommit: l'ordine SQL viene confermato dopo l'esecuzione.
- riga 35: l'oggetto Command viene associato alla connessione corrente
- riga 36: viene impostato il testo SQL da eseguire. Si tratta della query parametrizzata alla riga g di App.config.
- righe 37-44: vengono inizializzati i 4 parametri della query
- riga 46: la richiesta viene eseguita.
- righe 49-51: ricordate che se non c'era una transazione, è stata aperta una nuova connessione con il database, righe 26-30. In questo caso, deve essere chiusa. Se c'era una transazione, la connessione non deve essere chiusa, poiché è il livello [metier] a gestirla.
Gli altri due metodi si basano su quanto visto nel capitolo "Database":
// list of items
public List<Article> GetAllArticles() {
// item list - empty at start
List<Article> articles = new List<Article>();
// operation connection
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// opening connection
connexion.Open();
// executes sqlCommand with select query
SqlCeCommand sqlCommand = new SqlCeCommand(GetAllText, connexion);
using (SqlCeDataReader reader = sqlCommand.ExecuteReader()) {
// operating income
while (reader.Read()) {
// current line operation
articles.Add(new Article(reader.GetInt32(0), reader.GetString(1), reader.GetDecimal(2), reader.GetInt32(3), reader.GetInt32(4)));
}
}
}
// we return the result
return articles;
}
// article deletion
public void DeleteAllArticles() {
using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
// opening connection
connexion.Open();
// executes sqlCommand with update request
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) {
// using the configuration file
string connectionString = null;
string insertText;
string getAllText;
string deleteAllText;
try {
// connecting chain
connectionString = ConfigurationManager.ConnectionStrings["dbArticlesSqlServerCe"].ConnectionString;
// other parameters
insertText = ConfigurationManager.AppSettings["insertText"];
getAllText = ConfigurationManager.AppSettings["getAllText"];
deleteAllText = ConfigurationManager.AppSettings["deleteAllText"];
} catch (Exception e) {
Console.WriteLine("Erreur de configuration : {0}", e.Message);
return;
}
// layer creation [dao]
Dao dao = new Dao();
dao.ConnectionString = connectionString;
dao.DeleteAllText = deleteAllText;
dao.GetAllText = getAllText;
dao.InsertText = insertText;
// layer creation [job]
Metier metier = new Metier();
metier.Dao = dao;
metier.ConnectionString = connectionString;
// we create an array of articles
Article[] articles = new Article[2];
for (int i = 0; i < articles.Length; i++) {
articles[i] = new Article(0, "article", 100, 10, 1);
}
// we delete all articles
Console.WriteLine("Suppression de tous les articles...");
metier.DeleteAllArticles();
// insert the table outside the transaction
Console.WriteLine("Insertion des articles hors transaction...");
try {
metier.InsertArticlesOutOfTransaction(articles);
} catch (Exception e){
Console.WriteLine("Exception : {0}", e.Message);
}
// we display the articles
Console.WriteLine("Liste des articles");
AfficheArticles(metier);
// we delete all articles
Console.WriteLine("Suppression de tous les articles...");
metier.DeleteAllArticles();
// insert the array in a transaction
Console.WriteLine("Insertion des articles dans une transaction...");
metier.InsertArticlesInTransaction(articles);
// we display the articles
Console.WriteLine("Liste des articles");
AfficheArticles(metier);
}
private static void AfficheArticles(IMetier metier) {
// we display the articles
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: lo stesso vale per il livello [metier]
- righe 34-37: creazione di una tabella di 2 articoli con lo stesso nome. La tabella [articles] nel database SQL Server [dbarticles.sdf] ha un vincolo di unicità sul nome. L'inserimento del secondo elemento verrà quindi rifiutato. Se l'array viene inserito al di fuori di una transazione, il primo elemento verrà inserito per primo e rimarrà inserito. Se l'array viene inserito all'interno di una transazione, il primo elemento verrà inserito per primo, ma verrà rimosso al momento dell'esecuzione del rollback della transazione.
- righe 39-50: inserimento fuori transazione di 2 array di articoli e verifica.
- righe 52-59: come sopra, ma in una transazione
I risultati sono i seguenti:
- righe 5-6: l'inserimento al di fuori della transazione ha lasciato il primo elemento nel database
- riga 9: l'inserimento in una transazione non ha lasciato alcun articolo nel database
10.9.3. Conclusione
L'esempio precedente ha illustrato i vantaggi dei dati thread-local per la gestione delle transazioni. Non dovrebbe essere riprodotto così com'è. Framework come Spring, Nhibernate, ... utilizzano questa tecnica, ma la rendono ancora più trasparente: il livello [metier] può utilizzare le transazioni senza che il livello [dao] ne sia a conoscenza. Non ci sono transazioni nel codice del livello [dao]. Ciò si ottiene utilizzando una tecnica di proxy chiamata AOP (Aspects Oriented Programming). Ancora una volta, vi invitiamo a utilizzare questi framework.
10.10. Per saperne di più...
Per un approfondimento sul complesso campo della sincronizzazione dei thread, leggete il capitolo Threading del libro C# 3.0 citato nell'introduzione a questo documento. Esso presenta numerose tecniche di sincronizzazione per diversi tipi di situazioni.







