Skip to content

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:

1
2
3
4
5
6
7
8
Thread courant :
Thread courant : main
main : 04:19:00
main : 04:19:01
main : 04:19:02
main : 04:19:03
main : 04:19:04
^CAppuyez sur une touche pour continuer...
  • 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)
  1. Un thread T viene innanzitutto creato da uno dei costruttori presentati in precedenza, ad esempio:
Thread thread=new Thread(Start);

dove Start è un metodo con una delle due seguenti firme:

void Start();
void Start(object obj);

La creazione di un thread non ne avvia l'esecuzione.

  1. 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.
  2. 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.
  3. È possibile forzare la chiusura del thread T:
    1. T.Abort() richiede al thread T di terminare.
  4. È 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:

Début d'exécution de la méthode Affiche dans le Thread 0 : 10:30:44
Début d'exécution de la méthode Affiche dans le Thread 1 : 10:30:44
Début d'exécution de la méthode Affiche dans le Thread 2 : 10:30:44
Début d'exécution de la méthode Affiche dans le Thread 3 : 10:30:44
Début d'exécution de la méthode Affiche dans le Thread 4 : 10:30:44
Fin du thread Main à 10:30:44
Fin d'exécution de la méthode Affiche dans le Thread 0 : 10:30:45
Fin d'exécution de la méthode Affiche dans le Thread 1 : 10:30:45
Fin d'exécution de la méthode Affiche dans le Thread 2 : 10:30:45
Fin d'exécution de la méthode Affiche dans le Thread 3 : 10:30:45
Fin d'exécution de la méthode Affiche dans le Thread 4 : 10:30:45

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
                // si avvia l'esecuzione del thread i
                tâches[i].Start();

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:

1
2
3
4
5
6
Début d'exécution de la méthode Affiche dans le Thread 0 : 10:33:18
Début d'exécution de la méthode Affiche dans le Thread 1 : 10:33:18
Début d'exécution de la méthode Affiche dans le Thread 2 : 10:33:18
Début d'exécution de la méthode Affiche dans le Thread 3 : 10:33:18
Début d'exécution de la méthode Affiche dans le Thread 4 : 10:33:18
Fin du thread Main à 10:33:18
  • 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:
        Environment.Exit(0);

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:

Début d'exécution de la méthode Affiche dans le Thread 0 : 10:35:18
Début d'exécution de la méthode Affiche dans le Thread 1 : 10:35:18
Début d'exécution de la méthode Affiche dans le Thread 2 : 10:35:18
Début d'exécution de la méthode Affiche dans le Thread 3 : 10:35:18
Début d'exécution de la méthode Affiche dans le Thread 4 : 10:35:18
Fin d'exécution de la méthode Affiche dans le Thread 0 : 10:35:19
Fin d'exécution de la méthode Affiche dans le Thread 1 : 10:35:19
Fin d'exécution de la méthode Affiche dans le Thread 2 : 10:35:19
Fin d'exécution de la méthode Affiche dans le Thread 3 : 10:35:19
Fin d'exécution de la méthode Affiche dans le Thread 4 : 10:35:19
Fin du thread Main à 10:35:19
  • 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:

Thread t=new Thread(Run);

dove Run era un metodo con la seguente firma:

void Run();

È anche possibile utilizzare la seguente firma:

void Run(object obj);

Ciò consente di trasmettere informazioni al thread avviato. Pertanto

t.Start(obj1);

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:

1
2
3
4
5
6
Thread 0 terminé : début 11:18:50, durée programmée 1 s, fin 11:18:51, durée effective 00:00:01.0156250
Thread 1 terminé : début 11:18:50, durée programmée 2 s, fin 11:18:52, durée effective 00:00:02
Thread 2 terminé : début 11:18:50, durée programmée 3 s, fin 11:18:53, durée effective 00:00:03
Thread 3 terminé : début 11:18:50, durée programmée 4 s, fin 11:18:54, durée effective 00:00:04
Thread 4 terminé : début 11:18:50, durée programmée 5 s, fin 11:18:55, durée effective 00:00:05
Fin du thread Main à 11:18:55

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.

  1. riga 62: il contatore viene letto
  2. riga 66: il thread si ferma per 1 s. Perde quindi il controllo del processore
  3. 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:

A 12:00:56, le thread 3  a lu la valeur du compteur : 0
A 12:00:56, le thread 2  a lu la valeur du compteur : 0
A 12:00:56, le thread 1  a lu la valeur du compteur : 0
A 12:00:56, le thread 0  a lu la valeur du compteur : 0
A 12:00:56, le thread 4  a lu la valeur du compteur : 0
A 12:00:57, le thread 3  a écrit la valeur du compteur : 1
A 12:00:57, le thread 2  a écrit la valeur du compteur : 1
A 12:00:57, le thread 1  a écrit la valeur du compteur : 1
A 12:00:57, le thread 0  a écrit la valeur du compteur : 1
A 12:00:57, le thread 4  a écrit la valeur du compteur : 1
Nombre de threads générés : 1

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:

lock(obj){section critique}

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:

A 09:37:09, le thread 0 attend l'autorisation d'entrer dans la section critique
A 09:37:09, le thread 0 a lu la valeur du compteur : 0
A 09:37:09, le thread 1 attend l'autorisation d'entrer dans la section critique
A 09:37:09, le thread 2 attend l'autorisation d'entrer dans la section critique
A 09:37:09, le thread Main attend la fin du thread 2
A 09:37:10, le thread 0 a écrit la valeur du compteur : 1
A 09:37:10, le thread 1 a lu la valeur du compteur : 1
A 09:37:10, le thread 0 a quitté la section critique
A 09:37:11, le thread 1 a écrit la valeur du compteur : 2
A 09:37:11, le thread 1 a quitté la section critique
A 09:37:11, le thread 2 a lu la valeur du compteur : 2
A 09:37:12, le thread 2 a écrit la valeur du compteur : 3
A 09:37:12, le thread 2 a quitté la section critique
A 09:37:12, le thread Main a été prévenu de la fin du thread 2
A 09:37:12, le thread Main attend la fin du thread 1
A 09:37:12, le thread Main a été prévenu de la fin du thread 1
A 09:37:12, le thread Main attend la fin du thread 0
A 09:37:12, le thread Main a été prévenu de la fin du thread 0
Nombre de threads générés : 3
  • 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:

public Mutex()
crea un Mutex M
public bool WaitOne()
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.
public void ReleaseMutex()
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:

M.WaitOne();
// solo questo thread può entrare qui
// sezione critica
....
M.ReleaseMutex();

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:

AutoResetEvent barrière=new AutoresetEvent(bool état);

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:

barrière.WaitOne();
  • 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:
barrière.Set(); 

Può capitare che un thread voglia chiudere una barriera. Potrà farlo tramite:

barrière.Reset(); 

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:

Image

Il metodo Increment ha la seguente firma:

public static int Increment(ref int location);

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.

  1. Abbiamo un array in cui alcuni thread inseriscono dati (gli scrittori) e altri li leggono (i lettori).
  2. Gli scrittori sono uguali tra loro ma esclusivi: un solo scrittore alla volta può inserire i propri dati nell’array.
  3. I lettori sono uguali tra loro ma esclusivi: un solo lettore alla volta può leggere i dati inseriti nell'array.
  4. 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:

Méthode [Lire] démarrée par le thread n° L0
Méthode [Lire] démarrée par le thread n° L1
Méthode [Ecrire] démarrée par le thread n° E0
Méthode [Ecrire] démarrée par le thread n° E1
Fin de Main...
02:29:18 : L'écrivain E0 a écrit le nombre 607
02:29:19 : L'écrivain E0 a écrit le nombre 805
02:29:20 : L'écrivain E0 a écrit le nombre 650
Méthode [Ecrire] terminée par le thread n° E0
02:29:21 : Le lecteur L0 a lu le nombre 607
02:29:22 : Le lecteur L0 a lu le nombre 805
02:29:23 : Le lecteur L0 a lu le nombre 650
Méthode [Lire] terminée par le thread n° L0
02:29:24 : L'écrivain E1 a écrit le nombre 186
02:29:25 : L'écrivain E1 a écrit le nombre 881
02:29:26 : L'écrivain E1 a écrit le nombre 415
Méthode [Ecrire] terminée par le thread n° E1
02:29:27 : Le lecteur L1 a lu le nombre 186
02:29:28 : Le lecteur L1 a lu le nombre 881
02:29:29 : Le lecteur L1 a lu le nombre 415
Méthode [Lire] terminée par le thread n° L1

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:

plusieurs écrivains attendent pour un même guichet
i loro thread sono bloccati
le guichet se libère et le n° de l'écrivain suivant est appelé
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.
chaque écrivain regarde son n° et seul celui qui a le n° appelé
va au guichet. Les autres se remettent en attente.
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.

Image

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.
object jeton=new object();
  • Per recarsi allo sportello, i thread richiedono innanzitutto il token:
Monitor.Enter(jeton);

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:
while (! jeNeSuisPasCeluiQuiEstAttendu) {Monitor.Wait(jeton);}

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:
  1. // lavoro allo sportello
  2. ....

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:
1
2
3
4
5
6
7
8
// operatività allo sportello
....
// modifica delle condizioni di accesso allo sportello
...
// riattivazione dei thread in attesa del token
Monitor.PulseAll(jeton);
// rilascio del token
Monitor.Exit(jeton);

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:
Monitor.Exit(jeton);

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:

1
2
3
4
5
6
7
8
// acquisizione del token
Monitor.Enter(jeton);
// modifica delle condizioni di accesso allo sportello
....
// riattivazione dei thread in attesa del token
Monitor.PulseAll(jeton);
// rilascio del token
Monitor.Exit(jeton);

In definitiva, la struttura standard proposta nel capitolo Threading del libro C# 3.0 è la seguente:

  • definire il token di accesso allo sportello:
object jeton=new object();
  • richiedere l’accesso allo sportello:
lock(jeton){
    while (! jeNeSuisPasCeluiQuiEstAttendu) 
        Monitor.Wait(jeton);
}
// passaggio allo sportello
...
lock(jeton){...} 

è equivalente a

Monitor.Enter(jeton);
try{...} finally{Monitor.Exit(jeton);}

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:

Méthode [Lire] démarrée par le thread n° L0
Méthode [Lire] démarrée par le thread n° L2
Méthode [Lire] démarrée par le thread n° L1
Le lecteur L2 est en position 0
Le lecteur L1 est en position 1
Le lecteur L0 est en position 2
Méthode [Ecrire] démarrée par le thread n° E0
Méthode [Ecrire] démarrée par le thread n° E1
L'écrivain E0 est en position 0
L'écrivain E1 est en position 1
L'écrivain E2 est en position 2
Fin de Main...
Méthode [Ecrire] démarrée par le thread n° E2
12:09:05 : L'écrivain E0 a écrit le nombre 815
12:09:06 : L'écrivain E0 a écrit le nombre 990
12:09:07 : L'écrivain E0 a écrit le nombre 563
Méthode [Ecrire] terminée par le thread n° E0
12:09:08 : Le lecteur L2 a lu le nombre 815
12:09:09 : Le lecteur L2 a lu le nombre 990
12:09:10 : Le lecteur L2 a lu le nombre 563
Méthode [Lire] terminée par le thread n° L2
12:09:11 : L'écrivain E1 a écrit le nombre 411
12:09:12 : L'écrivain E1 a écrit le nombre 11
12:09:13 : L'écrivain E1 a écrit le nombre 54
Méthode [Ecrire] terminée par le thread n° E1
12:09:14 : Le lecteur L1 a lu le nombre 411
12:09:15 : Le lecteur L1 a lu le nombre 11
12:09:16 : Le lecteur L1 a lu le nombre 54
Méthode [Lire] terminée par le thread n° L1
12:09:17 : L'écrivain E2 a écrit le nombre 698
12:09:18 : L'écrivain E2 a écrit le nombre 448
12:09:19 : L'écrivain E2 a écrit le nombre 472
Méthode [Ecrire] terminée par le thread n° E2
12:09:20 : Le lecteur L0 a lu le nombre 698
12:09:21 : Le lecteur L0 a lu le nombre 448
12:09:22 : Le lecteur L0 a lu le nombre 472
Méthode [Lire] terminée par le thread n° L0

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:

  1. ThreadPool.QueueWorkItem(WaitCallBack)
  2. 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:

1
2
3
4
5
6
Nombre minimum de tâches bloquantes dans le pool : 2
Nombre minimum de tâches asynchrones dans le pool : 2
Nombre minimum de tâches bloquantes dans le pool après changement : 3
Nombre maximum de tâches bloquantes dans le pool : 500
Nombre maximum de tâches asynchrones dans le pool : 1000
Nombre maximum de tâches bloquantes dans le pool après changement : 5

Per l’esecuzione dei 7 thread:

A 03:07:37:04, le thread n° 0 va dormir pendant 10 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 3
A 03:07:37:04, le thread n° 2 va dormir pendant 12 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 2
A 03:07:37:04, le thread n° 1 va dormir pendant 11 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 2
A 03:07:38:04, le thread n° 3 va dormir pendant 13 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 1
A 03:07:38:54, le thread n° 4 va dormir pendant 14 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 0
A 03:07:47:04, le thread n° 0 se termine. Il était programmé pour durer 10 seconde(s). Il a duré 00:00:10 seconde(s)
A 03:07:47:04, le thread n° 5 va dormir pendant 15 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 0
A 03:07:48:04, le thread n° 1 se termine. Il était programmé pour durer 11 seconde(s). Il a duré 00:00:11 seconde(s)
A 03:07:48:04, le thread n° 6 va dormir pendant 16 seconde(s)
Nombre de threads pour tâches bloquantes disponibles dans le pool : 0
A 03:07:49:04, le thread n° 2 se termine. Il était programmé pour durer 12 seconde(s). Il a duré 00:00:12 seconde(s)
A 03:07:51:04, le thread n° 3 se termine. Il était programmé pour durer 13 seconde(s). Il a duré 00:00:14 seconde(s)
A 03:07:52:54, le thread n° 4 se termine. Il était programmé pour durer 14 seconde(s). Il a duré 00:00:15.5000000 seconde(s)
A 03:08:02:04, le thread n° 5 se termine. Il était programmé pour durer 15 seconde(s). Il a duré 00:00:25 seconde(s)
A 03:08:04:04, le thread n° 6 se termine. Il était programmé pour durer 16 seconde(s). Il a duré 00:00:27 seconde(s)
  • 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:

1
2
3
4
5
6
Fin du thread Main. Laissez les autres threads se terminer puis tapez [entrée] pour terminer...
Thread 0 terminé : début 05:19:46, durée programmée 1 s, fin 05:19:47, durée effective 00:00:01
Le thread System.ComponentModel.BackgroundWorker a rencontré l'erreur suivante : test....
Thread System.ComponentModel.BackgroundWorker annulé
Thread 1 terminé : début 05:19:46, durée programmée 2 s, fin 05:19:49, durée effective 00:00:03
Thread 2 terminé : début 05:19:46, durée programmée 3 s, fin 05:19:50, durée effective 00:00:04

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:

  1. il thread viene creato o richiesto a un pool di thread per soddisfare una richiesta di un utente
  2. 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].
  3. 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:
Thread.SetData(Thread.GetNamedDataSlot("clé"),valeur);
  • per recuperare il valore associato a clé:
Thread.GetData(Thread.GetNamedDataSlot("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:

1
2
3
4
5
6
7
8
9
Suppression de tous les articles...
Insertion des articles hors transaction...
Exception : A duplicate value cannot be inserted into a unique index. [ Table na
me = ARTICLES,Constraint name = UQ__ARTICLES__0000000000000010 ]
Liste des articles
[7,article,100,10,1]
Suppression de tous les articles...
Insertion des articles dans une transaction...
Liste des articles
  • 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.