Skip to content

10. Ausführungsthreads

10.1. Die Klasse „Thread“

Wenn eine Anwendung gestartet wird, wird sie in einem Ausführungsstrom ausgeführt, der als Thread bezeichnet wird. Die Klasse .NET, die einen Thread modelliert, ist die Klasse System.Threading.Thread und hat folgende Definition:

Konstruktoren

In den folgenden Beispielen werden wir ausschließlich die Konstruktoren von [1,3] verwenden. Der Konstruktor [1] akzeptiert als Parameter eine Methode mit der Signatur [2], c.a.d, die einen Parameter vom Typ object hat und kein Ergebnis zurückgibt. Der Konstruktor [3] akzeptiert als Parameter eine Methode mit der Signatur [4] oder c.a.d, die keinen Parameter hat und kein Ergebnis zurückgibt.

Eigenschaften

Einige nützliche Eigenschaften:

  • Thread CurrentThread: statische Eigenschaft, die eine Referenz auf den Thread liefert, in dem sich der Code befindet, der diese Eigenschaft angefordert hat
  • string Name: Der Name des Threads
  • bool IsAlive: Gibt an, ob der Thread gerade ausgeführt wird oder nicht.

Methoden

Die am häufigsten verwendeten Methoden sind die folgenden:

  • Start(), Start(object obj): Startet die asynchrone Ausführung des Threads und übergibt ihm gegebenenfalls Informationen in einem Typ object.
  • Abort(), Abort(object obj): zum erzwungenen Beenden eines Threads
  • Join(): Der Thread T1, der T2.Join ausführt, wird blockiert, bis der Thread T2 beendet ist. Es gibt Varianten, um das Warten nach einer bestimmten Zeit zu beenden.
  • Sleep(int n): statische Methode – der Thread, der die Methode ausführt, wird für n Millisekunden angehalten. Er verliert dabei die Prozessorausführung, die einem anderen Thread zugewiesen wird.

Sehen wir uns eine erste Anwendung an, die die Existenz eines Hauptausführungsthreads verdeutlicht, in dem die Funktion Main einer Klasse ausgeführt wird:


using System;
using System.Threading;

namespace Chap8 {
    class Program {
        static void Main(string[] args) {
            // Initialisierung des aktuellen Threads
            Thread main = Thread.CurrentThread;
            // Anzeige
            Console.WriteLine("Thread courant : {0}", main.Name);
            // Name wird geändert
            main.Name = "main";
            // Überprüfung
            Console.WriteLine("Thread courant : {0}", main.Name);

            // Endlosschleife
            while (true) {
                // Anzeige
                Console.WriteLine("{0} : {1:hh:mm:ss}", main.Name, DateTime.Now);
                // vorübergehende Unterbrechung
                Thread.Sleep(1000);
            }//while        
        }
    }
}
  • Zeile 8: Es wird eine Referenz auf den Thread abgerufen, in dem die Methode [main] ausgeführt wird
  • Zeilen 10–14: Sein Name wird angezeigt und geändert
  • Zeilen 17–22: Eine Schleife, die jede Sekunde eine Anzeige durchführt
  • Zeile 21: Der Thread, in dem die Methode [main] ausgeführt wird, wird für 1 Sekunde angehalten

Die Bildschirmausgabe sieht wie folgt aus:

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...
  • Zeile 1: Der aktuelle Thread hatte keinen Namen
  • Zeile 2: Er hat nun einen Namen
  • Zeilen 3–7: Die Anzeige erfolgt jede Sekunde
  • Zeile 8: Das Programm wird mit Strg-C abgebrochen.

10.2. Erstellung von Ausführungsthreads

Es gibt Anwendungen, bei denen Codeabschnitte „gleichzeitig“ in verschiedenen Ausführungsthreads ausgeführt werden. Wenn man sagt, dass threads gleichzeitig ausgeführt werden, handelt es sich oft um eine sprachliche Ungenauigkeit. Verfügt der Rechner nur über einen Prozessor – was nach wie vor häufig der Fall ist –, teilen sich die threads diesen Prozessor: Sie haben abwechselnd für einen kurzen Moment (einige Millisekunden) Zugriff darauf. Dies erzeugt die Illusion einer parallelen Ausführung. Die einem thread zugewiesene Zeit hängt von verschiedenen Faktoren ab, darunter seine Priorität, die einen Standardwert hat, aber auch programmgesteuert festgelegt werden kann. Wenn ein thread über den Prozessor verfügt, nutzt er diesen normalerweise während der gesamten ihm zugewiesenen Zeit. Er kann ihn jedoch vorzeitig freigeben:

  • indem es auf ein Ereignis wartet (Wait, Join)
  • indem er für eine bestimmte Zeit in den Ruhezustand wechselt (Sleep)
  1. Ein Thread T wird zunächst durch einen der oben vorgestellten Konstruktoren erstellt, zum Beispiel:
Thread thread=new Thread(Start);

wobei Start eine Methode mit einer der beiden folgenden Signaturen ist:

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

Die Erstellung eines Threads führt nicht zu dessen Start.

  1. Die Ausführung des Threads T wird durch T.Start() gestartet: Die an den Konstruktor von T übergebene Methode Start wird dann vom Thread T ausgeführt. Das Programm, das die Anweisung T.Start() ausführt, wartet nicht auf das Ende der Aufgabe T: Es springt sofort zur nächsten Anweisung. Es laufen also zwei Aufgaben parallel ab. Oft müssen sie miteinander kommunizieren können, um zu erfahren, wie weit die gemeinsam auszuführende Arbeit fortgeschritten ist. Dies ist das Problem der Thread-Synchronisation.
  2. Einmal gestartet, läuft der Thread T autonom ab. Er wird beendet, sobald die von ihm ausgeführte Methode Start ihre Arbeit beendet hat.
  3. Man kann den Thread T zwingen, zu enden:
    1. T.Abort() weist den Thread T an, sich zu beenden.
  4. Man kann auch mit T.Join() auf das Ende seiner Ausführung warten. Hierbei handelt es sich um eine blockierende Anweisung: Das Programm, das sie ausführt, wird blockiert, bis die Aufgabe T ihre Arbeit beendet hat. Dies ist eine Möglichkeit der Synchronisation.

Betrachten wir das folgende Programm:


using System;
using System.Threading;

namespace Chap8 {
    class Program {
        public static void Main() {
            // aktuellen Thread initialisieren
            Thread main = Thread.CurrentThread;
            // Thread mit einem Namen versehen
            main.Name = "Main";

            // Erstellung von Ausführungsthreads
            Thread[] tâches = new Thread[5];
            for (int i = 0; i < tâches.Length; i++) {
                // Thread i wird erstellt
                tâches[i] = new Thread(Affiche);
                // Der Name des Threads wird festgelegt
                tâches[i].Name =  i.ToString();
                // Start der Ausführung des Threads i
                tâches[i].Start();
            }

            // Ende der Hauptschleife
            Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}",main.Name,DateTime.Now);
        }

        public static void Affiche() {
            // Anzeige des Ausführungsbeginns
            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);
            // 1 Sekunde lang in den Ruhezustand versetzen
            Thread.Sleep(1000);
            // Anzeige des Endes der Ausführung
            Console.WriteLine("Fin d'exécution de la méthode Affiche dans le Thread {0} : {1:hh:mm:ss}", Thread.CurrentThread.Name, DateTime.Now);
        }
    }
}
  • Zeilen 8–10: Man gibt dem Thread, der die Methode [Main] ausführt, einen Namen.
  • Zeilen 13–21: Es werden 5 Threads erstellt und ausgeführt. Die Referenzen der Threads werden in einem Array gespeichert, um sie später wieder abrufen zu können. Jeder Thread führt die Methode Affiche aus den Zeilen 27–35 aus.
  • Zeile 20: Der Thread Nr. i wird gestartet. Dieser Vorgang ist nicht blockierend. Der Thread Nr. i wird parallel zu dem Thread der Methode [Main] ausgeführt, der ihn gestartet hat.
  • Zeile 24: Der Thread, der die Methode [Main] ausführt, wird beendet.
  • Zeilen 27–35: Die Methode [Affiche] führt Ausgabevorgänge durch. Sie gibt den Namen des Threads aus, der sie ausführt, sowie die Start- und Endzeit der Ausführung.
  • Zeile 31: Jeder Thread, der die Methode [Affiche] ausführt, wird für 1 Sekunde angehalten. Der Prozessor wird dann an einen anderen Thread übergeben, der auf den Prozessor wartet. Nach Ablauf der 1-Sekunden-Pause wird der angehaltene Thread wieder als Kandidat für den Prozessor in Betracht gezogen. Er erhält den Prozessor, sobald er an der Reihe ist. Dies hängt von verschiedenen Faktoren ab, darunter die Priorität der anderen Threads, die auf den Prozessor warten.

Die Ergebnisse lauten wie folgt:

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

Diese Ergebnisse sind sehr aufschlussreich:

  • Zunächst ist zu erkennen, dass der Start der Ausführung eines Threads nicht blockierend ist. Die Methode Main hat die Ausführung von 5 Threads parallel gestartet und ihre eigene Ausführung vor diesen abgeschlossen. Die Operation
                // Start der Ausführung des Threads i
                tâches[i].Start();

löst die Ausführung des Threads „tâches[i]“ aus, doch danach wird die Ausführung sofort mit der folgenden Anweisung fortgesetzt, ohne auf das Ende der Ausführung des Threads zu warten.

  • Alle erstellten Threads müssen die Methode Affiche ausführen. Die Ausführungsreihenfolge ist unvorhersehbar. Auch wenn im Beispiel die Ausführungsreihenfolge der Reihenfolge der Ausführungsanforderungen zu folgen scheint, lassen sich daraus keine allgemeinen Schlussfolgerungen ziehen. Das Betriebssystem verfügt hier über 6 Threads und einen Prozessor. Es verteilt die Prozessorleistung nach eigenen Regeln auf diese 6 Threads.
  • In den Ergebnissen ist eine Auswirkung der Methode Sleep zu erkennen. Im Beispiel ist es Thread 0, der als erster die Methode Affiche ausführt. Die Meldung zum Start der Ausführung wird angezeigt, anschließend führt er die Methode Sleep aus, die ihn für 1 Sekunde anhält. Er verliert daraufhin den Prozessor, der somit für einen anderen Thread verfügbar wird. Das Beispiel zeigt, dass Thread 1 diesen erhält. Thread 1 durchläuft denselben Ablauf wie die anderen Threads. Wenn die einsekündige Wartezeit von Thread 0 abgelaufen ist, kann dessen Ausführung fortgesetzt werden. Das System weist ihm den Prozessor zu, und er kann die Ausführung der Methode Affiche abschließen.

Ändern wir unser Programm so, dass die Methode Main mit den folgenden Anweisungen beendet wird:


            // Ende der Hand
            Console.WriteLine("Fin du thread " + main.Name);
            // Alle Threads werden angehalten
Environment.Exit(0);

Die Ausführung des neuen Programms liefert folgende Ergebnisse:

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
  • Zeilen 1–5: Die von der Funktion Main erstellten Threads beginnen mit der Ausführung und werden für 1 Sekunde unterbrochen
  • Zeile 6: Der Thread [Main] erhält die Prozessorsteuerung zurück und führt die Anweisung aus:
        Environment.Exit(0);

Dieser Befehl stoppt alle Threads der Anwendung und nicht nur den Thread Main.

Wenn die Methode Main auf das Ende der Ausführung der von ihr erstellten Threads warten möchte, kann sie die Methode Join der Klasse Thread verwenden:


        public static void Main() {
...
            // Warten auf alle Threads
            for (int i = 0; i < tâches.Length; i++) {
                // Warten auf das Ende der Ausführung von Thread i
                tâches[i].Join();
            }
            // Ende der Hauptschleife
            Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
}
  • Zeile 6: Der Thread [Main] wartet auf jeden einzelnen Thread. Er wird zunächst auf den Thread Nr. 1, dann auf den Thread Nr. 2 usw. blockiert. Wenn er schließlich die Schleife der Zeilen 2–5 verlässt, sind alle 5 von ihm gestarteten Threads beendet.

Man erhält dann folgende Ergebnisse:

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
  • Zeile 11: Der Thread [Main] wurde beendet, nachdem die von ihm gestarteten Threads beendet waren.

10.3. Vorteile von Threads

Nachdem wir nun die Existenz eines Standard-Threads aufgezeigt haben – nämlich den, der die Methode Main ausführt – und wissen, wie man weitere erstellt, wollen wir uns nun mit dem Nutzen von Threads für uns und den Gründen befassen, warum wir sie hier vorstellen. Es gibt eine Art von Anwendungen, die sich gut für den Einsatz von Threads eignen: Client-Server-Anwendungen im Internet. Diese werden wir im folgenden Kapitel vorstellen. In einer Client-Server-Anwendung im Internet beantwortet ein Server auf einem Rechner S1 Anfragen von Clients auf entfernten Rechnern C1, C2, ..., Cn.

Wir nutzen täglich Internetanwendungen, die diesem Schema entsprechen: Webdienste, E-Mail, Forenbesuche, Dateiübertragungen … Im obigen Schema muss der Server S1 die Clients Ci gleichzeitig bedienen. Nehmen wir das Beispiel eines Servers FTP (File Transfer Protocol), der Dateien an seine Clients ausliefert. Wir wissen, dass eine Dateiübertragung manchmal mehrere Minuten dauern kann. Es kommt natürlich nicht in Frage, dass ein einzelner Client den Server während einer solchen Zeitspanne allein beansprucht. Üblicherweise erstellt der Server so viele Ausführungs-Threads, wie es Clients gibt. Jeder Thread ist dann dafür zuständig, sich um einen bestimmten Client zu kümmern. Da der Prozessor zyklisch zwischen allen aktiven Threads des Rechners aufgeteilt wird, verbringt der Server ein wenig Zeit mit jedem Client und gewährleistet so die Parallelität des Dienstes.

In der Praxis nutzt der Server einen Thread-Pool mit einer begrenzten Anzahl von Threads, beispielsweise 50. Der 51. Client wird dann gebeten, zu warten.

10.4. Informationsaustausch zwischen Threads

In den vorangegangenen Beispielen wurde ein Thread wie folgt initialisiert:

Thread t=new Thread(Run);

wobei Run eine Methode mit folgender Signatur war:

void Run();

Es ist auch möglich, die folgende Signatur zu verwenden:

void Run(object obj);

Dadurch können Informationen an den gestarteten Thread übermittelt werden. So

t.Start(obj1);

wird der Thread t gestartet, der dann die Methode Run ausführt, die ihm konstruktionsbedingt zugeordnet wurde, wobei ihm der effektive Parameter obj1 übergeben wird. Hier ein Beispiel:


using System;
using System.Threading;

namespace Chap8 {
    class Program4 {
        public static void Main() {
            // aktuellen Thread initialisieren
            Thread main = Thread.CurrentThread;
            // Dem Thread wird ein Name zugewiesen
            main.Name = "Main";

            // Erstellung von Ausführungsthreads
            Thread[] tâches = new Thread[5];
            Data[] data = new Data[5];
            for (int i = 0; i < tâches.Length; i++) {
                // Erstellung des Threads i
                tâches[i] = new Thread(Sleep);
                // Festlegen des Thread-Namens
                tâches[i].Name = i.ToString();
                // Start der Ausführung von Thread i
                tâches[i].Start(data[i] = new Data { Début = DateTime.Now, Durée = i+1 });
            }
            // Warten auf alle Threads
            for (int i = 0; i < tâches.Length; i++) {
                // Warten auf das Ende der Ausführung von Thread i
                tâches[i].Join();
                // Anzeige des Ergebnisses
                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));
            }        
            // Ende der Hauptschleife
            Console.WriteLine("Fin du thread {0} à {1:hh:mm:ss}", main.Name, DateTime.Now);
        }

        public static void Sleep(object infos) {
            // Der Parameter wird abgerufen
            Data data = (Data)infos;
            // Wartezeit von „Durée“ Sekunden
            Thread.Sleep(data.Durée*1000);
            // Ende der Ausführung
            data.Fin = DateTime.Now;
        }
    }

    internal class Data {
        // Verschiedene Informationen
        public DateTime Début { get; set; }
        public int Durée { get; set; }
        public DateTime Fin { get; set; }
    }
}
  • Zeilen 45–50: Die an die Threads übergebenen Informationen vom Typ [Data]:
    • Début: Zeitpunkt des Ausführungsbeginns des Threads – vom startenden Thread festgelegt
    • Durée: Dauer des vom gestarteten Thread ausgeführten Sleep in Sekunden – festgelegt vom startenden Thread
    • Fin: Startzeitpunkt der Thread-Ausführung – festgelegt durch den startenden Thread
  • Zeilen 35–43: Die von den Threads ausgeführte Methode Sleep hat die Signatur void Sleep(object obj). Der effektive Parameter obj ist vom Typ [Data], der in Zeile 45 definiert ist.
  • Zeilen 15–22: Erstellung von 5 Threads
  • Zeile 17: Jeder Thread wird der Methode „Sleep“ in Zeile 35 zugeordnet
  • Zeile 21: Ein Objekt vom Typ [Data] wird an die Methode Start übergeben, die den Thread startet. In diesem Objekt wurden der Startzeitpunkt der Thread-Ausführung sowie die Dauer in Sekunden, während der der Thread „schlafen“ soll, vermerkt. Dieses Objekt wird im Array in Zeile 14 gespeichert.
  • Zeilen 24–30: Der Thread [Main] wartet auf das Ende aller von ihm gestarteten Threads.
  • Zeilen 28–29: Der Thread [Main] ruft das Objekt data[i] vom Thread Nr. i ab und zeigt dessen Inhalt an.
  • Zeilen 35–42: Die von den Threads ausgeführte Methode Sleep
  • Zeile 37: Der Parameter vom Typ [Data] wird abgerufen
  • Zeile 39: Das Feld Durée des Parameters wird verwendet, um die Dauer von Sleep festzulegen
  • Zeile 41: Das Feld Fin des Parameters wird initialisiert

Die Ergebnisse der Ausführung lauten wie folgt:

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

Dieses Beispiel zeigt, dass zwei Threads Informationen austauschen können:

  • Der aufrufende Thread kann die Ausführung des aufgerufenen Threads steuern, indem er ihm Informationen übermittelt
  • Der aufgerufene Thread kann Ergebnisse an den aufrufenden Thread zurückgeben.

Damit der aufgerufene Thread weiß, wann die von ihm erwarteten Ergebnisse verfügbar sind, muss er über das Ende des aufgerufenen Threads benachrichtigt werden. In diesem Fall hat er mithilfe der Methode Join auf dessen Beendigung gewartet. Es gibt noch andere Möglichkeiten, dies zu erreichen. Wir werden sie später betrachten.

10.5. Paralleler Zugriff auf gemeinsam genutzte Ressourcen

10.5.1. Nicht synchronisierter paralleler Zugriff

Im Abschnitt über den Informationsaustausch zwischen Threads wurden die Informationen nur zwischen zwei Threads und zu ganz bestimmten Zeitpunkten ausgetauscht. Dabei handelte es sich um eine klassische Parameterübergabe. Es gibt andere Fälle, in denen eine Information von mehreren Threads gemeinsam genutzt wird, die diese möglicherweise gleichzeitig lesen oder aktualisieren möchten. Dabei stellt sich das Problem der Integrität dieser Information. Nehmen wir an, die gemeinsam genutzte Information sei eine Struktur S mit verschiedenen Informationen I1, I2, … In.

  • Ein Thread T1 beginnt mit der Aktualisierung der Struktur S: Er ändert das Feld I1 und wird unterbrochen, bevor er die vollständige Aktualisierung der Struktur S abgeschlossen hat.
  • Ein Thread T2, der nun die Prozessorausführung übernimmt, liest daraufhin die Struktur S, um Entscheidungen zu treffen. Er liest eine Struktur in einem instabilen Zustand: Einige Felder sind auf dem neuesten Stand, andere nicht.

Diese Situation wird als Zugriff auf eine gemeinsam genutzte Ressource – in diesem Fall die Struktur S – bezeichnet und ist oft recht schwierig zu handhaben. Betrachten wir das folgende Beispiel, um die Probleme zu veranschaulichen, die dabei auftreten können:

  • Eine Anwendung generiert n Threads, wobei n als Parameter übergeben wird
  • Die gemeinsam genutzte Ressource ist ein Zähler, der von jedem erzeugten Thread erhöht werden muss
  • Am Ende der Anwendung wird der Wert des Zählers angezeigt. Man sollte also n erhalten.

Das Programm lautet wie folgt:


using System;
using System.Threading;

namespace Chap8 {
    class Program {

        // Klassenvariablen
        static int cptrThreads = 0;    // Thread-Zähler

        //main
        public static void Main(string[] args) {
            // Bedienungsanleitung
            const string syntaxe = "pg nbThreads";
            const int nbMaxThreads = 100;

            // Überprüfung der Anzahl der Argumente
            if (args.Length != 1) {
                // Fehler
                Console.WriteLine(syntaxe);
                // Beenden
                Environment.Exit(1);
            }
            // Prüfung der Argumentqualität
            int nbThreads = 0;
            bool erreur = false;
            try {
                nbThreads = int.Parse(args[0]);
                if (nbThreads < 1 || nbThreads > nbMaxThreads)
                    erreur = true;
            } catch {
                // Fehler
                erreur = true;
            }
            // Fehler?
            if (erreur) {
                // Fehler
                Console.Error.WriteLine("Nombre de threads incorrect (entre 1 et 100)");
                // Ende
                Environment.Exit(2);
            }
            // Erstellung und Generierung von Threads
            Thread[] threads = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // Erstellung
                threads[i] = new Thread(Incrémente);
                // Benennung
                threads[i].Name = "" + i;
                // Start
                threads[i].Start();
            }//für
            // Warten auf das Ende der Threads
            for (int i = 0; i < nbThreads; i++) {
                threads[i].Join();
            }
            // Zähler anzeigen
            Console.WriteLine("Nombre de threads générés : " + cptrThreads);
        }

        public static void Incrémente() {
            // erhöht den Thread-Zähler
            // Zähler auslesen
            int valeur = cptrThreads;
            // Überwachung
            Console.WriteLine("A {0:hh:mm:ss}, le thread {1}  a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
            // Warten
            Thread.Sleep(1000);
            // Zähler inkrementieren
            cptrThreads = valeur + 1;
            // Nachverfolgung
            Console.WriteLine("A {0:hh:mm:ss}, le thread {1}  a écrit la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
        }
    }
}

Wir werden nicht näher auf den bereits behandelten Teil zur Thread-Erzeugung eingehen. Konzentrieren wir uns stattdessen auf die Methode Incrémente in Zeile 59, die von jedem Thread verwendet wird, um den statischen Zähler cptrThreads in Zeile 8 zu inkrementieren.

  1. Zeile 62: Der Zähler wird ausgelesen
  2. Zeile 66: Der Thread hält 1 s lang an. Er verliert somit die Prozessorsteuerung
  3. Zeile 68: Der Zähler wird erhöht

Schritt 2 dient lediglich dazu, den Thread zu zwingen, die Prozessorsteuerung abzugeben. Diese wird dann an einen anderen Thread übergeben. In der Praxis gibt es keine Garantie dafür, dass ein Thread nicht zwischen dem Zeitpunkt, an dem er den Zähler ausliest, und dem Zeitpunkt, an dem er ihn inkrementiert, unterbrochen wird. Selbst wenn man cptrThreads++ schreibt und so den Anschein eines einzigen Befehls erweckt, besteht das Risiko, die Prozessorsteuerung zwischen dem Zeitpunkt des Auslesens des Zählers und dem Zeitpunkt des Schreibens seines um 1 erhöhten Werts zu verlieren. Tatsächlich wird die übergeordnete Operation cptrThreads++ auf Prozessorebene in mehrere elementare Befehle zerlegt. Der Schritt 2 mit einer Wartezeit von einer Sekunde dient daher lediglich dazu, dieses Risiko systematisch zu vermeiden.

Die mit 5 Threads erzielten Ergebnisse lauten wie folgt:

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

Beim Betrachten dieser Ergebnisse wird deutlich, was geschieht:

  • Zeile 1: Ein erster Thread liest den Zähler aus. Er findet den Wert 0. Er hält 1 s lang an und gibt somit den Prozessor ab
  • Zeile 2: Ein zweiter Thread übernimmt nun den Prozessor und liest ebenfalls den Wert des Zählers. Dieser steht immer noch auf 0, da der vorherige Thread ihn noch nicht erhöht hat. Auch er hält 1 s lang an und verliert seinerseits den Prozessor.
  • Zeilen 1–5: Innerhalb von 1 s haben alle 5 Threads Zeit, abzulaufen und den Wert 0 zu lesen.
  • Zeilen 6–10: Wenn sie nacheinander wieder aktiv werden, erhöhen sie den von ihnen gelesenen Wert 0 und schreiben den Wert 1 in den Zähler, was das Hauptprogramm (Main) in Zeile 11 bestätigt.

Wo liegt das Problem? Der zweite Thread hat einen falschen Wert gelesen, da der erste Thread unterbrochen wurde, bevor er seine Aufgabe – die Aktualisierung des Zählers im Fenster – abgeschlossen hatte. Dies führt uns zum Begriff der kritischen Ressource und der kritischen Section eines Programms:

  • Eine kritische Ressource ist eine Ressource, die jeweils nur von einem Thread gleichzeitig gehalten werden kann. In diesem Fall ist der Zähler die kritische Ressource.
  • Eine kritische Sequenz eines Programms ist eine Folge von Anweisungen im Ausführungsfluss eines Threads, während der dieser auf eine kritische Ressource zugreift. Es muss sichergestellt werden, dass während dieser kritischen Sequenz nur dieser Thread Zugriff auf die Ressource hat.

In unserem Beispiel ist der kritische Abschnitt der Code zwischen dem Auslesen des Zählers und dem Schreiben seines neuen Werts:


            // Zählerstand
            int valeur = cptrThreads;
            // Warten
            Thread.Sleep(1000);
            // Zählerinkrementierung
cptrThreads = valeur + 1;

Um diesen Code auszuführen, muss sichergestellt sein, dass ein Thread allein ist. Er kann unterbrochen werden, aber während dieser Unterbrechung darf kein anderer Thread denselben Code ausführen. Die Plattform .NET bietet verschiedene Werkzeuge, um den exklusiven Zugriff auf kritische Codeabschnitte zu gewährleisten. Wir sehen uns nun einige davon an.

10.5.2. Die lock-Klausel

Mit der „lock“-Klausel lässt sich ein kritischer Abschnitt wie folgt abgrenzen:

lock(obj){section critique}

obj muss eine Objektreferenz sein, die für alle Threads sichtbar ist, die den kritischen Abschnitt ausführen. Die lock-Klausel stellt sicher, dass jeweils nur ein Thread den kritischen Abschnitt ausführt. Das vorherige Beispiel lässt sich wie folgt umschreiben:


using System;
using System.Threading;

namespace Chap8 {
    class Program2 {

        // Klassenvariablen
        static int cptrThreads = 0;    // Thread-Zähler
        static object synchro = new object(); // Synchronisationsobjekt

        //main
        public static void Main(string[] args) {
    ...
            // Warten auf das Ende der Threads
            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);
            }
            // Zähler anzeigen
            Console.WriteLine("Nombre de threads générés : " + cptrThreads);
        }

        public static void Incrémente() {
            // erhöht den Thread-Zähler
            // Es wird exklusiver Zugriff auf den Zähler angefordert
            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) {
                // Zähler auslesen
                int valeur = cptrThreads;
                // Nachverfolgung
                Console.WriteLine("A {0:hh:mm:ss}, le thread {1}  a lu la valeur du compteur : {2}", DateTime.Now, Thread.CurrentThread.Name, cptrThreads);
                // Warten
                Thread.Sleep(1000);
                // Zähler inkrementieren
                cptrThreads = valeur + 1;
                // Überwachung
                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);
        }
    }
}
  • Zeile 9: synchro ist das Objekt, das die Synchronisation aller Threads ermöglicht.
  • Zeilen 16–23: Die Methode [Main] wartet auf die Threads in umgekehrter Reihenfolge ihrer Erstellung.
  • Zeilen 29–40: Der kritische Abschnitt der Methode Incrémente wurde durch die Klausel lock umschlossen.

Die mit 3 Threads erzielten Ergebnisse lauten wie folgt:

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
  • Thread 0 betritt als erster den kritischen Abschnitt: Zeilen 1, 2, 6, 8
  • Die beiden anderen Threads werden blockiert, solange Thread 0 den kritischen Abschnitt nicht verlassen hat: Zeilen 3 und 4
  • Anschließend ist Thread 1 an der Reihe: Zeilen 7, 9, 10
  • Anschließend ist Thread 2 an der Reihe: Zeilen 11, 12, 13
  • Zeile 14: Der Thread „Main“, der auf das Ende von Thread 2 gewartet hat, wird benachrichtigt
  • Zeile 15: Der Thread „Main“ wartet nun auf das Ende von Thread 1. Dieser ist bereits beendet. Der Thread „Main“ wird sofort darüber informiert, Zeile 16.
  • Zeilen 17–18: Der gleiche Vorgang findet mit Thread 0 statt
  • Zeile 19: Die Anzahl der Threads ist korrekt

10.5.3. Die Klasse „Mutex“

Auch die Klasse System.Threading.Mutex ermöglicht es, kritische Abschnitte abzugrenzen. Sie unterscheidet sich von der lock-Klausel hinsichtlich der Sichtbarkeit:

  • Die `lock`-Klausel ermöglicht die Synchronisation von Threads derselben Anwendung
  • Die Mutex-Klasse ermöglicht die Synchronisation von Threads verschiedener Anwendungen.

Wir werden den folgenden Konstruktor und die folgenden Methoden verwenden:

public Mutex()
erstellt ein Mutex M
public bool WaitOne()
Der Thread T1, der die Operation M.WaitOne() ausführt, fordert die Eigentumsrechte am Synchronisationsobjekt M an. Wenn das Mutex M von keinem Thread gehalten wird (was zu Beginn der Fall ist), wird es dem Thread T1 „übergeben“, der es angefordert hat. Wenn wenig später ein Thread T2 dieselbe Operation ausführt, wird er blockiert. Denn ein Mutex kann nur einem Thread gehören. Er wird freigegeben, sobald der Thread T1 den von ihm gehaltenen Mutex M freigibt. So können mehrere Threads in Erwartung des Mutex M blockiert sein.
public void ReleaseMutex()
Der Thread T1, der die Operation M.ReleaseMutex() ausführt, gibt den Besitz des Mutex Mutex M ab. Wenn der Thread T1 die Prozessorsteuerung verliert, kann das System diesen an einen der auf den Mutex M wartenden Threads vergeben. Nur einer erhält ihn seinerseits, während die anderen, die auf M warten, blockiert bleiben

Ein Mutex M verwaltet den Zugriff auf eine gemeinsam genutzte Ressource R. Ein Thread fordert die Ressource R über M.WaitOne() an und gibt sie über M.ReleaseMutex() zurück. Ein kritischer Codeabschnitt, der jeweils nur von einem einzigen Thread ausgeführt werden darf, ist eine gemeinsam genutzte Ressource. Die Synchronisation der Ausführung des kritischen Abschnitts kann wie folgt erfolgen:

M.WaitOne();
// Nur dieser Thread darf hier eintreten
// kritischer Abschnitt
....
M.ReleaseMutex();

wobei M ein Objekt vom Typ Mutex ist. Man darf nicht vergessen, ein nicht mehr benötigtes Mutex freizugeben, damit ein anderer Thread in den kritischen Abschnitt eintreten kann; andernfalls erhalten die Threads, die auf das nie freigegebene Mutex warten, niemals Zugriff auf den Prozessor.

Wenn wir das soeben Gelernte am vorherigen Beispiel in die Praxis umsetzen, sieht unsere Anwendung wie folgt aus:


using System;
using System.Threading;

namespace Chap8 {
    class Program3 {

        // Klassenvariablen
        static int cptrThreads = 0;    // Thread-Zähler
        static Mutex synchro = new Mutex(); // Synchronisationsobjekt

        //main
        public static void Main(string[] args) {
    ...
        }

        public static void Incrémente() {
....
            synchro.WaitOne();
            try {
...
            } finally {
...
                synchro.ReleaseMutex();
            }
        }
    }
}
  • Zeile 9: Das Synchronisationsobjekt der Threads ist nun ein Mutex.
  • Zeile 18: Beginn des kritischen Abschnitts – nur ein Thread darf diesen betreten. Es wird so lange gewartet, bis das Mutex synchro freigegeben ist.
  • Zeile 33: Da ein Mutex immer freigegeben werden muss, unabhängig davon, ob eine Ausnahme auftritt oder nicht, wird der kritische Abschnitt mit einem try/finally-Block behandelt, um das Mutex im finally freizugeben.
  • Zeile 23: Das Mutex wird freigegeben, sobald der kritische Abschnitt durchlaufen ist.

Die erzielten Ergebnisse sind dieselben wie zuvor.

10.5.4. Die Klasse AutoResetEvent

Ein Objekt AutoResetEvent ist eine Barriere, die jeweils nur einen Thread durchlässt, genau wie die beiden vorherigen Werkzeuge lock und Mutex. Ein Objekt AutoResetEvent wird wie folgt erstellt:

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

Der Boolesche Wert état gibt an, ob die Schranke geschlossen (false) oder geöffnet (true) ist. Ein Thread, der die Schranke passieren möchte, gibt dies wie folgt an:

barrière.WaitOne();
  • Ist die Barriere offen, passiert der Thread sie und die Barriere wird hinter ihm wieder geschlossen. Wenn mehrere Threads gewartet haben, ist sichergestellt, dass nur einer durchkommt.
  • Ist die Barriere geschlossen, wird der Thread blockiert. Ein anderer Thread wird sie zu gegebener Zeit öffnen. Dieser Zeitpunkt hängt vollständig von der zu lösenden Aufgabe ab. Die Barriere wird durch den folgenden Befehl geöffnet:
barrière.Set(); 

Es kann vorkommen, dass ein Thread eine Barriere schließen möchte. Dies kann er mit folgendem Befehl tun:

barrière.Reset(); 

Ersetzt man im vorherigen Beispiel das Objekt Mutex durch ein Objekt vom Typ AutoResetEvent, lautet der Code wie folgt:


using System;
using System.Threading;

namespace Chap8 {
    class Program4 {

        // Klassenvariablen
        static int cptrThreads = 0;    // Thread-Zähler
        static EventWaitHandle synchro = new AutoResetEvent(false); // Synchronisationsobjekt

        //Hauptprogramm
        public static void Main(string[] args) {
....
            // Die Barriere des kritischen Abschnitts wird geöffnet
            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();
            // Warten auf das Ende der Threads
...
            // Zähler anzeigen
            Console.WriteLine("Nombre de threads générés : " + cptrThreads);
        }

        public static void Incrémente() {
            // Erhöht den Thread-Zähler
            // Es wird exklusiver Zugriff auf den Zähler angefordert
...
            synchro.WaitOne();
            try {
...
            } finally {
                // Die Ressource wird freigegeben
...
                synchro.Set();
            }
        }
    }
}
  • Zeile 9: Die Barriere wird geschlossen angelegt. Sie wird vom Thread Main in Zeile 16 geöffnet.
  • Zeile 27: Der Thread, der für die Inkrementierung des Thread-Zählers zuständig ist, fordert die Erlaubnis zum Betreten des kritischen Abschnitts an. Die verschiedenen Threads sammeln sich vor der geschlossenen Barriere an. Wenn der Thread Main diese öffnet, wird einer der wartenden Threads passieren.
  • Zeile 33: Sobald er seine Arbeit beendet hat, öffnet er die Barriere wieder, sodass ein anderer Thread eintreten kann.

Man erhält ähnliche Ergebnisse wie zuvor.

10.5.5. Die Klasse Interlocked

Die Klasse Interlocked ermöglicht es, eine Gruppe von Operationen atomar auszuführen. In einer Operationsgruppe *atomique* werden entweder alle Operationen von dem Thread ausgeführt, der die Gruppe ausführt, oder gar keine. Es entsteht kein Zustand, in dem einige Operationen ausgeführt wurden und andere nicht. Die Synchronisationsobjekte lock, Mutex und AutoResetEvent dienen alle dazu, atomique zu einer Gruppe von Operationen zu machen. Dieses Ergebnis wird auf Kosten der Blockierung von Threads erzielt. Die Klasse Interlocked ermöglicht es, bei einfachen, aber recht häufig auftretenden Operationen die Blockierung von Threads zu vermeiden. Die Klasse Interlocked bietet die folgenden statischen Methoden:

Image

Die Methode Increment hat folgende Signatur:

public static int Increment(ref int location);

Sie ermöglicht es, den Parameter location um 1 zu erhöhen. Die Operation ist garantiert atomique.

Unser Programm zur Zählung der Threads könnte dann wie folgt aussehen:


using System;
using System.Threading;

namespace Chap8 {
    class Program5 {

        // Klassenvariablen
        static int cptrThreads = 0;    // Thread-Zähler

        //main
        public static void Main(string[] args) {
...
        }

        public static void Incrémente() {
            // erhöht den Thread-Zähler
            Interlocked.Increment(ref cptrThreads);
        }
    }
}
  • Zeile 17: Der Thread-Zähler wird atomar erhöht.

10.6. Paralleler Zugriff auf mehrere gemeinsam genutzte Ressourcen

10.6.1. Ein Beispiel

In unseren vorherigen Beispielen wurde eine einzige Ressource von den verschiedenen Threads gemeinsam genutzt. Die Situation kann sich komplizieren, wenn es mehrere Ressourcen gibt und diese voneinander abhängig sind. Insbesondere kann es zu einer Deadlock-Situation kommen. Diese Situation, die auch als deadlock bezeichnet wird, liegt vor, wenn zwei Threads gegenseitig aufeinander warten. Betrachten wir die folgenden, zeitlich aufeinanderfolgenden Aktionen:

  • Ein Thread T1 erlangt die Kontrolle über einen Mutex M1, um Zugriff auf eine gemeinsam genutzte Ressource R1 zu erhalten
  • Ein Thread T2 erlangt die Kontrolle über einen Mutex M2, um auf eine gemeinsam genutzte Ressource R2 zugreifen zu können
  • Der Thread T1 fordert den Mutex M2 an. Er wird blockiert.
  • Der Thread T2 fordert den Mutex M1 an. Er wird blockiert.

Hier warten die Threads T1 und T2 gegenseitig aufeinander. Dieser Fall tritt auf, wenn Threads zwei gemeinsam genutzte Ressourcen benötigen: die Ressource R1, die durch den Mutex M1 gesteuert wird, und die Ressource R2, die durch den Mutex M2 gesteuert wird. Eine mögliche Lösung besteht darin, beide Ressourcen gleichzeitig mithilfe eines einzigen Mutex M anzufordern. Dies ist jedoch nicht immer möglich, wenn dies beispielsweise zu einer langen Blockierung einer ressourcenintensiven Ressource führt. Eine andere Lösung besteht darin, dass ein Thread, der über M1 verfügt und M2 nicht erhalten kann, daraufhin M1 freigibt, um eine gegenseitige Blockierung zu vermeiden.

  1. Es gibt ein Array, in das Threads Daten schreiben (die Schreiber) und andere diese lesen (die Leser).
  2. Die Schreiber sind untereinander gleich, aber exklusiv: Es kann jeweils nur ein Schreiber seine Daten in das Array schreiben.
  3. Die Leser sind untereinander gleich, schließen sich jedoch gegenseitig aus: Es kann jeweils nur ein Leser die im Array abgelegten Daten lesen.
  4. Ein Leser kann die Daten aus dem Array nur lesen, wenn ein Schreiber sie dort abgelegt hat, und ein Schreiber kann nur dann neue Daten in das Array schreiben, wenn die darin enthaltenen Daten von einem Leser gelesen wurden.

Man kann zwei Arten von gemeinsam genutzten Ressourcen unterscheiden:

  • die schreibbare Tabelle: Es darf jeweils nur ein Schreiber darauf zugreifen.
  • die Lese-Tabelle: Es darf jeweils nur ein Leser darauf zugreifen.

sowie eine Reihenfolge für die Nutzung dieser Ressourcen:

  • Ein Leser muss immer erst nach einem Schreiber kommen.
  • Ein Schreiber muss immer erst nach einem Leser kommen, außer beim ersten Mal.

Der Zugriff auf diese beiden Ressourcen kann mit zwei Barrieren vom Typ AutoResetEvent gesteuert werden:

  • Die Sperre peutEcrire kontrolliert den Zugriff der Schreiber auf die Tabelle.
  • Die Schranke peutLire wird den Zugang der Leser zur Tafel kontrollieren.
  • Die Schranke peutEcrire wird zunächst in geöffneter Position angelegt, sodass ein erster Autor passieren kann, während alle anderen zurückgehalten werden.
  • Die Barriere peutLire wird zunächst geschlossen angelegt, wodurch alle Leser blockiert werden.
  • Sobald ein Schreiber seine Arbeit beendet hat, öffnet er die Schranke peutLire, um einen Leser hereinzulassen.
  • Wenn ein Leser seine Arbeit beendet hat, öffnet er die Barriere peutEcrire, um einen Schreiber hereinzulassen.

Das Programm, das diese ereignisgesteuerte Synchronisation veranschaulicht, lautet wie folgt:


using System;
using System.Threading;

namespace Chap8 {
    class Program {
        // Verwendung von Lese- und Schreib-Threads
        // veranschaulicht die Verwendung von Synchronisationsereignissen


        // Klassenvariablen
        static int[] data = new int[3];    // gemeinsam genutzte Ressource zwischen Lese- und Schreib-Threads
        static Random objRandom = new Random(DateTime.Now.Second);    // Ein Zufallszahlengenerator
        static AutoResetEvent peutLire;    // weist darauf hin, dass der Inhalt von „data“ gelesen werden kann
        static AutoResetEvent peutEcrire;    // signalisiert, dass der Inhalt von „data“ geschrieben werden kann

        //main
        public static void Main(string[] args) {

            // die Anzahl der zu generierenden Threads
            const int nbThreads = 2;

            // Initialisierung der Flags
            peutLire = new AutoResetEvent(false);    // kann noch nicht gelesen werden
            peutEcrire = new AutoResetEvent(true);    // Man kann bereits schreiben

            // Erstellung der Lesethreads
            Thread[] lecteurs = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // Erstellung
                lecteurs[i] = new Thread(Lire);
                lecteurs[i].Name = "L" + i.ToString();
                // Start
                lecteurs[i].Start();
            }

            // Erstellung der Schreib-Threads
            Thread[] écrivains = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // Erstellung
                écrivains[i] = new Thread(Ecrire);
                écrivains[i].Name = "E" + i.ToString();
                // Start
                écrivains[i].Start();
            }

            //Ende der Hand
            Console.WriteLine("Fin de Main...");
        }

        // Inhalt des Arrays lesen
        public static void Lire() {
...
        }

        // In das Array schreiben
        public static void Ecrire() {
....
        }
    }
}
  • Zeile 11: Das Array data ist die gemeinsam genutzte Ressource zwischen den Lese- und Schreib-Threads. Es wird von den Lese-Threads zum Lesen und von den Schreib-Threads zum Schreiben gemeinsam genutzt.
  • Zeile 13: Das Objekt peutLire dient dazu, die Lesethreads darüber zu informieren, dass sie das Array data lesen können. Es wird vom Schreibthread auf „wahr“ gesetzt, der das Array data gefüllt hat. Es wird in Zeile 23 auf false initialisiert. Ein Schreib-Thread muss das Array zunächst füllen, bevor er das Ereignis peutLire an vrai übergibt.
  • Zeile 14: Das Objekt peutEcrire dient dazu, die Schreib-Threads darüber zu informieren, dass sie in das Array data schreiben können. Es wird vom Lese-Thread auf „true“ gesetzt, nachdem dieser das gesamte Array data verarbeitet hat. Es wird in Zeile 24 auf true initialisiert. Tatsächlich ist das Array data schreibfrei.
  • Zeilen 27–34: Erstellung und Start der Lesethreads
  • Zeilen 37–44: Erstellung und Start der Schreib-Threads

Die von den Lesethreads ausgeführte Methode Lire lautet wie folgt:


public static void Lire() {
            // Nachverfolgung
            Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
            // Man muss auf die Leseerlaubnis warten
            peutLire.WaitOne();
            // Tabelle lesen
            for (int i = 0; i < data.Length; i++) {
                //1 Sekunde warten
                Thread.Sleep(1000);
                // Anzeige
                Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
            }
            // Schreiben möglich
            peutEcrire.Set();
            // Weiter
            Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
        }
  • Zeile 5: Es wird darauf gewartet, dass ein Schreib-Thread meldet, dass das Array gefüllt wurde. Sobald dieses Signal empfangen wird, darf nur einer der auf dieses Signal wartenden Lese-Threads weiterfahren.
  • Zeilen 7–12: Auswertung des Arrays data mit einem Sleep in der Mitte, um den Thread zu zwingen, die CPU freizugeben.
  • Zeile 14: Teilt den Schreib-Threads mit, dass das Array gelesen wurde und erneut gefüllt werden kann.

Die von den Schreib-Threads ausgeführte Methode Ecrire lautet wie folgt:


public static void Ecrire() {
            // Weiter
            Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
            // Man muss auf die Schreibberechtigung warten
            peutEcrire.WaitOne();
            // Tabelle schreiben
            for (int i = 0; i < data.Length; i++) {
                //1 Sekunde warten
                Thread.Sleep(1000);
                // Anzeige
                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]);
            }
            // Lesen möglich
            peutLire.Set();
            // Weiterverfolgung
            Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
        }
  • Zeile 5: Es wird darauf gewartet, dass ein Lesethread signalisiert, dass das Array gelesen wurde. Sobald dieses Signal empfangen wird, kann nur einer der auf dieses Signal wartenden Schreib-Threads weiterfahren.
  • Zeilen 7–13: Auswertung des Arrays data mit einem Sleep in der Mitte, um den Thread zu zwingen, die CPU freizugeben.
  • Zeile 15: Teilt den Lesethreads mit, dass das Array gefüllt wurde und erneut gelesen werden kann.

Die Ausführung liefert folgende Ergebnisse:

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

Folgende Punkte sind zu beachten:

  • Es gibt tatsächlich jeweils nur einen Leser, obwohl dieser im kritischen Abschnitt Lire die Prozessorausführung verliert.
  • Es gibt tatsächlich jeweils nur einen Schreiber, obwohl dieser im kritischen Abschnitt Ecrire die CPU-Kontrolle verliert
  • Ein Leser liest nur, wenn es etwas im Array zu lesen gibt
  • Ein Schreiber schreibt erst, wenn das Array vollständig gelesen wurde

10.6.2. Die Klasse „Monitor“

Im vorherigen Beispiel:

  • gibt es zwei gemeinsam genutzte Ressourcen zu verwalten
  • Für eine bestimmte Ressource sind die Threads gleichberechtigt.

Wenn die Schreib-Threads bei der Anweisung peutEcrire.WaitOne blockiert sind, wird einer von ihnen – egal welcher – durch die Operation peutEcrire.Set freigegeben. Wenn die vorherige Operation die Barriere für einen bestimmten Schreib-Thread öffnen soll, wird die Sache komplizierter.

Man kann dies mit einer Einrichtung vergleichen, die an Schaltern Kunden empfängt, wobei jeder Schalter auf einen bestimmten Bereich spezialisiert ist. Wenn der Kunde eintrifft, zieht er am Ticketautomaten eine Wartemarke für Schalter X und setzt sich dann hin. Jede Wartemarke ist nummeriert, und die Kunden werden über einen Lautsprecher anhand ihrer Nummer aufgerufen. Während der Wartezeit kann der Kunde tun, was er möchte. Er kann lesen oder ein wenig dösen. Er wird jedes Mal durch den Lautsprecher geweckt, der ankündigt, dass die Nr. Y am Schalter X aufgerufen wird. Wenn er gemeint ist, steht der Kunde auf und begibt sich zum Schalter X, andernfalls macht er weiter mit dem, was er gerade getan hat.

Hier kann man analog vorgehen. Nehmen wir das Beispiel der Schriftsteller:

plusieurs écrivains attendent pour un même guichet
Ihre Threads sind blockiert
le guichet se libère et le n° de l'écrivain suivant est appelé
Der Thread, der das Array im Lesemodus verwendet hat, teilt den Schreib-Threads mit, dass das Array verfügbar ist. Er oder ein anderer Thread hat den Schreib-Thread festgelegt, der die Barriere passieren muss.
chaque écrivain regarde son n° et seul celui qui a le n° appelé
va au guichet. Les autres se remettent en attente.
Jeder Thread prüft, ob er der Ausgewählte ist. Wenn ja, passiert er die Barriere. Wenn nicht, wartet er weiter.

Die Klasse „Monitor“ ermöglicht die Umsetzung dieses Szenarios.

Image

Wir beschreiben nun eine Standardkonstruktion (pattern), die im Kapitel Threading des in der Einleitung dieses Dokuments erwähnten Buches „C# 3.0“ vorgestellt wird und in der Lage ist, Probleme mit Barrieren mit Zugangsbedingungen zu lösen.

  • Zunächst greifen die Threads, die sich eine Ressource (den Schalter, …) teilen, über ein Objekt darauf zu, das wir als „Token“ bezeichnen. Um die Barriere zu öffnen, die zum Schalter führt, benötigt man das Token, und es gibt nur ein einziges Token. Die Threads müssen sich das Token daher untereinander weitergeben.
object jeton=new object();
  • Um zum Schalter zu gelangen, fordern die Threads zunächst das Token an:
Monitor.Enter(jeton);

Ist der Token frei, wird er dem Thread zugewiesen, der den vorherigen Vorgang ausgeführt hat; andernfalls wird der Thread in die Warteschlange für den Token gestellt.

  • Erfolgt der Zugang zum Schalter ungeordnet, c.a.d. Ist es unerheblich, wer als Nächstes hereinkommt, reicht der vorherige Vorgang aus. Der Thread, der das Token besitzt, begibt sich zum Schalter. Erfolgt der Zugang geordnet, prüft der Thread, der das Token besitzt, ob er die Bedingung für den Gang zum Schalter erfüllt:
while (! jeNeSuisPasCeluiQuiEstAttendu) {Monitor.Wait(jeton);}

Ist der Thread nicht derjenige, der am Schalter erwartet wird, gibt er seine Reihe ab, indem er das Token zurückgibt. Er wechselt in einen blockierten Zustand. Er wird wieder aktiviert, sobald das Token für ihn wieder verfügbar ist. Dann prüft er erneut, ob er die Bedingung für den Gang zum Schalter erfüllt. Die Operation Monitor.Wait(Token), die das Token freigibt, kann nur ausgeführt werden, wenn der Thread Eigentümer des Tokens ist. Ist dies nicht der Fall, wird eine Ausnahme ausgelöst.

  • Der Thread, der die Bedingung für den Gang zum Schalter prüft, begibt sich dorthin:
  1. // Arbeit am Schalter
  2. ....

Bevor der Thread den Schalter verlässt, muss er sein Token zurückgeben, da sonst die Threads, die auf das Token warten, auf unbestimmte Zeit blockiert bleiben. Es gibt zwei verschiedene Situationen:

  • Im ersten Fall ist der Thread, der das Token besitzt, auch derjenige, der den auf das Token wartenden Threads signalisiert, dass dieses frei ist. Dies geschieht wie folgt:
1
2
3
4
5
6
7
8
// Arbeit am Schalter
....
// Änderung der Zugangsbedingungen zum Schalter
...
// Wecken der auf das Token wartenden Threads
Monitor.PulseAll(jeton);
// Freigabe des Tokens
Monitor.Exit(jeton);

In Zeile 6 weckt er die auf das Token wartenden Threads auf. Dieses Aufwachen bedeutet, dass sie nun berechtigt sind, das Token zu erhalten. Das bedeutet jedoch nicht, dass sie es sofort erhalten. In Zeile 8 wird das Token freigegeben. Alle berechtigten Threads erhalten das Token nacheinander in unvorhersehbarer Reihenfolge. Dies gibt ihnen die Möglichkeit, erneut zu prüfen, ob sie die Zugangsbedingung erfüllen. Der Thread, der das Token freigegeben hat, hat diese Bedingung in Zeile 4 geändert, um einem neuen Thread den Einstieg zu ermöglichen. Der erste, der die Bedingung erfüllt, behält das Token und ist an der Reihe, zum Schalter zu gehen.

  • Die zweite Situation tritt ein, wenn der Thread, der das Token besitzt, nicht derjenige ist, der den auf das Token wartenden Threads signalisieren muss, dass dieses frei ist. Er muss es dennoch freigeben, da der Thread, der für das Senden dieses Signals zuständig ist, im Besitz des Tokens sein muss. Dies geschieht durch den folgenden Befehl:
Monitor.Exit(jeton);

Das Token ist nun verfügbar, aber die Threads, die darauf warten (sie haben einen Wait(Token)-Befehl ausgeführt), werden nicht darüber informiert. Diese Aufgabe wird einem anderen Thread übertragen, der zu einem bestimmten Zeitpunkt einen Code ausführt, der in etwa wie folgt aussieht:

1
2
3
4
5
6
7
8
// Token-Erwerb
Monitor.Enter(jeton);
// Änderung der Zugangsbedingung zum Schalter
....
// Wecken der auf das Token wartenden Threads
Monitor.PulseAll(jeton);
// Freigabe des Tokens
Monitor.Exit(jeton);

Letztendlich lautet die im Kapitel Threading des Buches „C# 3.0“ vorgeschlagene Standardkonstruktion wie folgt:

  • Definition des Zugriffstokens für den Schalter:
object jeton=new object();
  • Zugriff auf das Fenster anfordern:
lock(jeton){
    while (! jeNeSuisPasCeluiQuiEstAttendu) 
        Monitor.Wait(jeton);
}
// Wechsel zum Schalter
...
lock(jeton){...} 

entspricht

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

Es ist zu beachten, dass in diesem Schema das Token sofort freigegeben wird, sobald die Barriere passiert wurde. Ein anderer Thread kann dann die Zugangsbedingung prüfen. Die vorstehende Konstruktion lässt somit alle Threads zu, die die Zugangsbedingung prüfen. Wenn dies nicht gewünscht ist, kann man Folgendes schreiben:

lock(jeton){
    while (! jeNeSuisPasCeluiQuiEstAttendu) 
        Monitor.Wait(jeton);
     // Wechsel zum Schalter
    ...
}

wobei das Token erst nach dem Besuch am Schalter freigegeben wird.

  • die Zugangsbedingung zum Schalter ändern und die anderen Threads darüber informieren
lock(jeton){
     // Änderung der Zugangsbedingung zum Schalter
    ...
     // die auf das Token wartenden Threads benachrichtigen
    Monitor.PulseAll(jeton);
}

Im obigen Beispiel kann die Zugriffsbedingung nur von dem Thread geändert werden, der das Token besitzt. Man könnte auch schreiben:

     // Zugriffsbedingung für den Schalter ändern
    ...
     // die auf das Token wartenden Threads benachrichtigen
    Monitor.PulseAll(jeton);
     // das Token freigeben
    Monitor.Exit(jeton);

wenn der Thread das Token bereits besitzt.

Mit diesen Informationen können wir die Lese-/Schreib-Anwendung umschreiben, indem wir eine Reihenfolge für die Leser und Schreiber beim Zugriff auf ihre jeweiligen Kanäle festlegen. Der Code lautet wie folgt:


using System;
using System.Threading;

namespace Chap8 {
    class Program2 {
        // Verwendung von Lese- und Schreib-Threads
        // veranschaulicht die Verwendung von Synchronisationsereignissen


        // Klassenvariablen
        static int[] data = new int[3];            // Gemeinsam genutzte Ressource zwischen Lese- und Schreib-Threads
        static Random objRandom = new Random(DateTime.Now.Second);    // Ein Zufallszahlengenerator
        static object peutLire = new object();        // zeigt an, dass der Inhalt von „data“ gelesen werden kann
        static object peutEcrire = new object();    // signalisiert, dass der Inhalt von „data“ geschrieben werden kann
        static bool lectureAutorisée = false;    // um das Lesen des Arrays zuzulassen
        static bool écritureAutorisée = false;    // um das Schreiben in das Array zu erlauben
        static string[] ordreLecture;    // legt die Reihenfolge der Leser fest
        static string[] ordreEcriture;    // legt die Reihenfolge der Schreiber fest
        static int lecteurSuivant = 0;    // gibt die Nummer des nächsten Lesers an
        static int écrivainSuivant = 0;    // gibt die Nummer des nächsten Schreibers an

        //main
        public static void Main(string[] args) {

            // die Anzahl der zu generierenden Threads
            const int nbThreads = 5;

            // Erstellung der Lesethreads
            Thread[] lecteurs = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // Erstellung
                lecteurs[i] = new Thread(Lire);
                lecteurs[i].Name = "L" + i.ToString();
                // Start
                lecteurs[i].Start();
            }

            // Erstellung der Leseanweisung
            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);
            }

            // Erstellung der Schreib-Threads
            Thread[] écrivains = new Thread[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // Erstellung
                écrivains[i] = new Thread(Ecrire);
                écrivains[i].Name = "E" + i.ToString();
                // Start
                écrivains[i].Start();
            }

            // Erstellung der Schreibreihenfolge
            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);
            }

             // Schreibberechtigung
            lock (peutEcrire) {
               écritureAutorisée = true;
                Monitor.Pulse(peutEcrire);
            }


            //Ende des Durchlaufs
            Console.WriteLine("Fin de Main...");
        }

        // Inhalt der Tabelle lesen
        public static void Lire() {
...
        }

        // In das Array schreiben
        public static void Ecrire() {
...
        }
    }
}

Der Zugriff auf den Leseschnittstellenport ist an folgende Bedingungen geknüpft:

  • Zeile 13: das Token peutLire
  • Zeile 15: der Boolesche Wert lectureAutorisée
  • Zeile 17: das geordnete Array der Leser. Die Leser begeben sich in der Reihenfolge dieses Arrays, das ihre Namen enthält, zum Leseschalter.
  • Zeile 19: lecteurSuivant gibt die Nummer des nächsten Lesers an, der zum Leseschalter gehen darf.

Der Zugang zum Schreibschalter hängt von folgenden Faktoren ab:

  • Zeile 14: das Token peutEcrire
  • Zeile 16: der Boolesche Wert écritureAutorisée
  • Zeile 18: das geordnete Array der Schreibenden. Die Schreibenden begeben sich in der Reihenfolge dieses Arrays, das ihre Namen enthält, zum Schreibschalter.
  • Zeile 20: écrivainSuivant gibt die Nummer des nächsten Schreibers an, der zum Schalter gehen darf.

Die übrigen Elemente des Codes sind wie folgt:

  • Zeilen 29–36: Erstellung und Start der Lesethreads. Sie werden alle blockiert, da das Lesen nicht erlaubt ist (Zeile 15).
  • Zeilen 39–43: Die Reihenfolge ihres Auftretens am Schalter erfolgt in umgekehrter Reihenfolge ihrer Erstellung.
  • Zeilen 46–53: Erstellung und Start der Schreib-Threads. Sie werden alle blockiert, da das Schreiben nicht zulässig ist (Zeile 16).
  • Zeilen 56–60: Ihre Reihenfolge am Schalter erfolgt in der Reihenfolge ihrer Erstellung.
  • Zeile 64: Das Schreiben wird freigegeben
  • Zeile 65: Die Schreib-Threads werden darüber informiert, dass sich etwas geändert hat.

Die Methode Lire lautet wie folgt:


        public static void Lire() {
            // Nachverfolgung
            Console.WriteLine("Méthode [Lire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
            // Es muss auf die Leseberechtigung gewartet werden
            lock (peutLire) {
                while (!lectureAutorisée || ordreLecture[lecteurSuivant] != Thread.CurrentThread.Name) {
                    Monitor.Wait(peutLire);
                }
                // Tabelle lesen
                for (int i = 0; i < data.Length; i++) {
                    //1 Sekunde warten
                    Thread.Sleep(1000);
                    // Anzeige
                    Console.WriteLine("{0:hh:mm:ss} : Le lecteur {1} a lu le nombre {2}", DateTime.Now, Thread.CurrentThread.Name, data[i]);
                }
                 // nächster Leser
                lectureAutorisée = false;
                lecteurSuivant++;
                // Die Schreiber werden darüber informiert, dass sie schreiben können
                lock (peutEcrire) {
                    écritureAutorisée = true;
                    Monitor.PulseAll(peutEcrire);
                }

                // Weiterverfolgung
                Console.WriteLine("Méthode [Lire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
            }
}
  • Der gesamte Zugang zum Schalter wird durch die Methode lock in den Zeilen 5–27 gesteuert. Der Leser, der das Token erhält, behält es während seines gesamten Aufenthalts am Schalter.
  • Zeilen 6–8: Ein Lesegerät, das das Token aus Zeile 5 erfasst hat, gibt es wieder frei, wenn das Auslesen nicht zulässig ist oder es noch nicht an der Reihe ist.
  • Zeilen 10–15: Durchlauf am Schalter (Auswertung der Tabelle)
  • Zeilen 17–18: Der Thread ändert die Zugangsbedingungen für den Lese-Schalter. Es ist zu beachten, dass er weiterhin über das Lese-Token verfügt und dass diese Änderungen einem Leser noch nicht den Durchgang ermöglichen.
  • Zeilen 20–23: Der Thread ändert die Zugangsbedingungen für den Schreibschalter und benachrichtigt alle wartenden Schreiber, dass sich etwas geändert hat.
  • Zeile 27: Der Thread „lock“ wird beendet, das Token „peutLire“ wird freigegeben. Ein Lesethread könnte es nun in Zeile 5 erwerben, würde jedoch die Zugangsbedingung nicht erfüllen, da der boolesche Wert „lectureAutorisée“ auf „falsch“ steht. Außerdem bleiben alle Threads, die auf das Token peutLire warten, weiterhin in der Warteschlange, da die Operation PulseAll(peutLire) noch nicht stattgefunden hat.

Die Methode Ecrire lautet wie folgt:


        public static void Ecrire() {
            // Weiterverfolgung
            Console.WriteLine("Méthode [Ecrire] démarrée par le thread n° {0}", Thread.CurrentThread.Name);
            // Man muss auf die Schreibberechtigung warten
            lock (peutEcrire) {
                while (!écritureAutorisée || ordreEcriture[écrivainSuivant] != Thread.CurrentThread.Name) {
                    Monitor.Wait(peutEcrire);
                }
                // Tabelle schreiben
                for (int i = 0; i < data.Length; i++) {
                    //1 Sekunde warten
                    Thread.Sleep(1000);
                    // Anzeige
                    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]);
                }
                // nächster Schreiber
                écritureAutorisée = false;
                écrivainSuivant++;
                // Leser, die auf das Token warten, werden geweckt peutLire
                lock (peutLire) {
                    lectureAutorisée = true;
                    Monitor.PulseAll(peutLire);
                }
                // Weiterverfolgung
                Console.WriteLine("Méthode [Ecrire] terminée par le thread n° {0}", Thread.CurrentThread.Name);
            }
}
  • Der gesamte Zugriff auf den Schreibschalter wird durch den Befehl lock in den Zeilen 5–27 gesteuert. Der Schreiber, der das Token erhält, behält es während seines gesamten Aufenthalts am Schalter.
  • Zeilen 6–8: Ein Schreiber, der das Token in Zeile 5 erworben hat, gibt es frei, wenn die Schreibaktion nicht zulässig ist oder er noch nicht an der Reihe ist.
  • Zeilen 10–16: Besuch am Schalter (Auswertung des Arrays)
  • Zeilen 18–19: Der Thread ändert die Zugangsbedingungen für den Schreibschalter. Es ist zu beachten, dass er weiterhin über das Schreib-Token verfügt und dass diese Änderungen einem Schreiber noch nicht den Zugang ermöglichen.
  • Zeilen 21–24: Der Thread ändert die Zugangsbedingungen für den Leseschalter und benachrichtigt alle wartenden Leser, dass sich etwas geändert hat.
  • Zeile 27: Der Thread „lock“ wird beendet, das Token „peutEcrire“ wird freigegeben. Ein Schreib-Thread könnte es nun in Zeile 5 erwerben, würde jedoch die Zugangsbedingung nicht erfüllen, da der boolesche Wert „écritureAutorisée“ auf „falsch“ steht. Außerdem bleiben alle Threads, die auf das Token peutEcrire warten, weiterhin in der Warteschlange und warten auf eine neue Operation PulseAll(peutEcrire).

Ein Ausführungsbeispiel lautet wie folgt:

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. Thread-Pools

Bisher haben wir zur Verwaltung von Threads:

  • haben wir sie mit „Thread T = new Thread(...)“ erstellt
  • und anschließend mit T.Start() ausgeführt

Im Kapitel „Datenbanken“ haben wir gesehen, dass es bei bestimmten SGBD möglich ist, Pools mit offenen Verbindungen zu nutzen:

  • n-Verbindungen werden beim Start des Pools geöffnet
  • Wenn ein Thread eine Verbindung anfordert, erhält er eine der offenen Verbindungen aus dem Pool
  • Wenn der Thread die Verbindung schließt, wird sie nicht geschlossen, sondern an den Pool zurückgegeben

Die Verwendung eines Verbindungspools ist auf Codeebene transparent. Der Vorteil liegt in der Leistungssteigerung: Das Öffnen einer Verbindung ist ressourcenintensiv. Hier können 10 geöffnete Verbindungen Hunderte von Anfragen bedienen.

Ein ähnliches System gibt es für Threads:

  • min Threads werden beim Start des Pools erstellt. Der Wert von min wird mit der Methode ThreadPool.SetMinThreads(min1,min2) festgelegt. Ein Thread-Pool kann zur Ausführung von blockierenden oder nicht blockierenden, sogenannten asynchronen Aufgaben verwendet werden. Der erste Parameter min1 legt die Anzahl der blockierenden Threads fest, der zweite min2 die Anzahl der asynchronen Threads. Die aktuellen Werte dieser beiden Parameter können mit ThreadPool.GetMinThreads(out min1,out min2) abgerufen werden.
  • Ist diese Anzahl nicht ausreichend, erstellt der Pool weitere Threads, um die Anfragen bis zur Obergrenze von max Threads zu bedienen. Der Wert von max wird mit der Methode ThreadPool.SetMaxThreads(max1,max2) festgelegt. Die beiden Parameter haben dieselbe Bedeutung wie in der Methode SetMinThreads. Die aktuellen Werte dieser beiden Parameter können über ThreadPool.GetMaxThreads(out max1,out max2) abgerufen werden. Sobald die Anzahl der Threads gemäß max1 erreicht ist, werden Thread-Anforderungen für blockierende Aufgaben in die Warteschlange gestellt, bis ein freier Thread im Pool verfügbar ist.

Ein Thread-Pool bietet verschiedene Vorteile:

  • Wie beim Verbindungspool wird Zeit bei der Erstellung von Threads eingespart: 10 Threads können Hunderte von Anfragen bedienen.
  • Die Anwendung wird abgesichert: Durch die Festlegung einer maximalen Anzahl von Threads wird verhindert, dass die Anwendung durch zu viele Anfragen überlastet wird. Diese werden in eine Warteschlange gestellt.

Um einem Thread aus dem Pool eine Aufgabe zuzuweisen, verwendet man eine der beiden folgenden Methoden:

  1. ThreadPool.QueueWorkItem(WaitCallBack)
  2. ThreadPool.QueueWorkItem(WaitCallBack, object)

wobei WaitCallBack eine beliebige Methode mit der Signatur void WaitCallBack(object) ist. Methode 1 weist einen Thread an, die Methode WaitCallBack auszuführen, ohne ihr einen Parameter zu übergeben. Methode 2 tut dasselbe, übergibt jedoch einen Parameter vom Typ object an die Methode WaitCallBack.

Hier ist ein Programm, das diese Konzepte veranschaulicht:


using System;
using System.Threading;

namespace Chap8 {
    class Program {
        public static void Main() {
            // aktuellen Thread initialisieren
            Thread main = Thread.CurrentThread;
            // dem Thread wird ein Name zugewiesen
            main.Name = "Main";

            // Es wird ein Thread-Pool verwendet
            int min1, min2;
            // Festlegen der Mindestanzahl blockierender Threads
            ThreadPool.GetMinThreads(out min1, out min2);
            Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool : {0}", min1);
            Console.WriteLine("Nombre minimum de tâches asynchrones dans le pool : {0}", min2);
            ThreadPool.SetMinThreads(3, min2);
            ThreadPool.GetMinThreads(out min1, out min2);
            Console.WriteLine("Nombre minimum de tâches bloquantes dans le pool après changement : {0}", min1);
            // Festlegen der maximalen Anzahl blockierender Threads
            int max1, max2;
            ThreadPool.GetMaxThreads(out max1, out max2);
            Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool : {0}", max1);
            Console.WriteLine("Nombre maximum de tâches asynchrones dans le pool : {0}", max2);
            ThreadPool.SetMaxThreads(5, max2);
            ThreadPool.GetMaxThreads(out max1, out max2);
            Console.WriteLine("Nombre maximum de tâches bloquantes dans le pool après changement : {0}", max1);
            // 7 Threads werden ausgeführt
            for (int i = 0; i < 7; i++) {
                // Die Ausführung von Thread i in einem Pool wird gestartet
                ThreadPool.QueueUserWorkItem(Sleep, new Data2 { Numéro = i.ToString(), Début = DateTime.Now, Durée = i + 10 });
            }
            // Ende der Hauptschleife
            Console.Write("Tapez [entrée] pour terminer le thread {0} à {1:hh:mm:ss:FF}", main.Name, DateTime.Now);
            // Warten
            Console.ReadLine();
        }

        public static void Sleep(object infos) {
            // Parameter wird abgerufen
            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);
            // Status des Pools
            int cpt1, cpt2;
            ThreadPool.GetAvailableThreads(out cpt1, out cpt2);
            Console.WriteLine("Nombre de threads pour tâches bloquantes disponibles dans le pool : {0}", cpt1);
            // Warteschleife für Dauer Sekunden
            Thread.Sleep(data.Durée * 1000);
            // Ende der Ausführung
            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 {
        // Verschiedene Informationen
        public string Numéro { get; set; }
        public DateTime Début { get; set; }
        public int Durée { get; set; }
        public DateTime Fin { get; set; }
    }
}
  • Zeilen 15–17: Die aktuelle Mindestanzahl der beiden Thread-Typen im Thread-Pool wird abgefragt und angezeigt
  • Zeile 18: Die Mindestanzahl an Threads für blockierende Aufgaben wird auf 2 geändert
  • Zeilen 19–21: Die neuen Mindestwerte werden angezeigt
  • Zeilen 22–28: Auf die gleiche Weise wird die maximale Anzahl an Threads für blockierende Aufgaben festgelegt: 5
  • Zeilen 30–33: Es werden 7 Aufgaben in einem Pool mit 5 Threads ausgeführt. 5 Aufgaben sollten einen Thread erhalten, die ersten beiden schnell, da immer 2 Threads verfügbar sind, die anderen drei mit einer Wartezeit von 0,5 Sekunden. 2 Aufgaben sollten warten, bis ein Thread frei wird.
  • Zeile 32: Die Aufgaben führen die Methode Sleep aus den Zeilen 40–54 aus und übergeben ihr dabei einen Parameter vom Typ Data2, der in den Zeilen 56–62 definiert ist.
  • Zeile 40: Die von den Aufgaben ausgeführte Methode Sleep
  • Zeile 42: Der an die Methode Sleep übergebene Parameter wird abgerufen.
  • Zeile 43: Die Aufgabe identifiziert sich auf der Konsole
  • Zeilen 45–47: Die Anzahl der derzeit verfügbaren Threads wird angezeigt. Wir wollen sehen, wie sich diese Zahl entwickelt.
  • Zeile 49: Die Aufgabe wird für einige Sekunden angehalten (blockierende Aufgabe).
  • Zeile 52: Wenn sie wieder aktiv wird, werden einige Informationen zu ihrem Konto angezeigt.

Die folgenden Ergebnisse wurden erzielt.

Für die Thread-Nummern min und max im 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

Für die Ausführung der 7 Threads:

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)
  • Zeilen 1–6: Die ersten drei Aufgaben werden nacheinander ausgeführt. Sie finden sofort einen verfügbaren Thread (MinThreads=3) und werden anschließend in den Ruhezustand versetzt.
  • Zeilen 7–9: Bei den Aufgaben 3 und 4 dauert es etwas länger. Für jede von ihnen war kein Thread frei. Es musste ein Thread erstellt werden. Dieser Mechanismus ist bis zu 5 möglich (MaxThreads=5).
  • Zeile 10: Es sind keine Threads mehr verfügbar: Die Aufgaben 5 und 6 müssen warten.
  • Zeilen 11–12: Aufgabe 0 wird beendet. Aufgabe 5 übernimmt den Thread.
  • Zeilen 13–14: Aufgabe 1 wird beendet. Aufgabe 6 übernimmt den Thread.
  • Zeilen 17–21: Die Aufgaben werden nacheinander beendet.

10.8. Die Klasse BackgroundWorker

10.8.1. Beispiel 1

Die Klasse BackgroundWorker gehört zum Namensraum [System.ComponentModel]. Sie wird wie ein Thread verwendet, weist jedoch Besonderheiten auf, die sie in bestimmten Fällen interessanter machen können als die Klasse [Thread]:

  • Sie löst die folgenden Ereignisse aus:
  • DoWork: Ein Thread hat die Ausführung von BackgroundWorker angefordert
  • ProgressChanged: Das Objekt BackgroundWorker hat die Methode ReportProgress ausgeführt. Diese dient dazu, einen Ausführungsgrad in Prozent anzugeben.
  • RunWorkerCompleted: Das Objekt BackgroundWorker hat seine Arbeit beendet. Dies kann normal, durch Abbruch oder aufgrund einer Ausnahme geschehen sein.

Diese Ereignisse machen das Objekt BackgroundWorker in grafischen Benutzeroberflächen nützlich: Eine lang andauernde Aufgabe wird einem BackgroundWorker übertragen, das mit dem Ereignis ProgressChanged über seinen Fortschritt und mit dem Ereignis RunWorkerCompleted über seinen Abschluss berichten kann. Die vom BackgroundWorker auszuführende Arbeit erfolgt über eine Methode, die dem Ereignis DoWork zugeordnet wurde.

  • Es ist möglich, die Abbruch der Aufgabe zu veranlassen. In einer grafischen Benutzeroberfläche kann eine lang andauernde Aufgabe somit vom Benutzer abgebrochen werden.
  • Die Objekte BackgroundWorker gehören zu einem Pool und werden nach Bedarf wiederverwendet. Eine Anwendung, die ein Objekt BackgroundWorker benötigt, erhält dieses aus dem Pool, der ihr einen bereits vorhandenen, aber ungenutzten Thread zuweist. Die Wiederverwendung von Threads anstelle der jedes Mal neu erstellten Threads verbessert die Leistung.

Wir verwenden dieses Tool in der vorherigen Anwendung für den Fall, dass der Zugriff auf das Fenster unkontrolliert erfolgt:


using System;
using System.Threading;
using System.ComponentModel;

namespace Chap8 {
    class Program2 {
        // Verwendung von Lese- und Schreib-Threads
        // veranschaulicht die gleichzeitige Nutzung von gemeinsam genutzten Ressourcen und Synchronisation

        // Klassenvariablen
        const int nbThreads = 2;                    // Gesamtzahl der Threads
        static int nbLecteursTerminés = 0;        // Anzahl der beendeten Threads
        static int[] data = new int[5];            // gemeinsames Array für Lese- und Schreib-Threads
        static object appli;                            // Synchronisiert den Zugriff auf die Anzahl der beendeten Threads
        static Random objRandom = new Random(DateTime.Now.Second);    // Ein Zufallszahlengenerator
        static AutoResetEvent peutLire;        // signalisiert, dass der Inhalt des Arrays gelesen werden kann
        static AutoResetEvent peutEcrire;        // signalisiert, dass in das Array geschrieben werden kann
        static AutoResetEvent finLecteurs;    // meldet das Ende der Leser

        //main
        public static void Main(string[] args) {

            // dem Thread wird ein Name zugewiesen
            Thread.CurrentThread.Name = "Main";

            // Initialisierung der Flags
            peutLire = new AutoResetEvent(false);        // Es kann noch nicht gelesen werden
            peutEcrire = new AutoResetEvent(true);    // Schreiben ist bereits möglich
            finLecteurs = new AutoResetEvent(false);    // Anwendung noch nicht beendet

            // Synchronisiert den Zugriff auf den Zähler für beendete Threads
            appli = new object();                

            // Erstellung der Lesethreads
            MyBackgroundWorker[] lecteurs = new MyBackgroundWorker[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // Erstellung
                lecteurs[i] = new MyBackgroundWorker();
                lecteurs[i].Numéro = "L" + i;
                lecteurs[i].DoWork += Lire;
                lecteurs[i].RunWorkerCompleted += EndLecteur;
                // Start
                lecteurs[i].RunWorkerAsync();
            }

            // Erstellung der Schreib-Threads
            MyBackgroundWorker[] écrivains = new MyBackgroundWorker[nbThreads];
            for (int i = 0; i < nbThreads; i++) {
                // Erstellung
                écrivains[i] = new MyBackgroundWorker();
                écrivains[i].Numéro = "E" + i;
                écrivains[i].DoWork += Ecrire;
                // Start
                écrivains[i].RunWorkerAsync();
            }

            // Warten auf das Ende aller Threads
            finLecteurs.WaitOne();
            //Ende der Verarbeitung
            Console.WriteLine("Fin de Main...");
        }

        public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
...
        }

        // Inhalt des Arrays lesen
        public static void Lire(object sender, DoWorkEventArgs infos) {
...
        }

        // In das Array schreiben
        public static void Ecrire(object sender, DoWorkEventArgs infos) {
...
        }
    }

    // Thread
    internal class MyBackgroundWorker : BackgroundWorker {
        // Verschiedene Informationen
        public string Numéro { get; set; }
    }

}

Wir gehen hier nur auf die Änderungen ein:

  • Die Klasse Thread wird in den Zeilen 79–82 durch die Klasse MyBackgroundWorker ersetzt. Die Klasse BackgroundWorker wurde abgeleitet, um dem Thread eine Nummer zuzuweisen. Man hätte auch anders vorgehen können, indem man der Methode RunWorkerAsync in den Zeilen 43 und 54 ein Objekt übergeben hätte, das die Nummer des Threads enthält.
  • Zeile 58: Die Methode Main wird beendet, nachdem alle Lesethreads ihre Arbeit erledigt haben. Zu diesem Zweck zählt der Zähler nbLecteursTerminés in Zeile 12 die Anzahl der Lesethreads, die ihre Arbeit beendet haben. Dieser Zähler wird durch die Methode EndLecteur in den Zeilen 63–65 inkrementiert, die jedes Mal ausgeführt wird, wenn ein Lesethread beendet wird. Dieses Verfahren steuert das Ereignis AutoResetEvent finLecteurs in Zeile 18, auf das sich die Methode Main. in Zeile 59 synchronisiert
  • Zeile 16: Da mehrere Lesethreads möglicherweise gleichzeitig den Zähler nbLecteursTerminés inkrementieren möchten, wird durch das Synchronisationsobjekt appli ein exklusiver Zugriff darauf gewährleistet. Dieser Fall ist zwar unwahrscheinlich, aber theoretisch möglich.
  • Zeilen 35–44: Erstellung der Lesethreads
  • Zeile 38: Erstellung des Threads vom Typ MyBackgroundWorker
  • Zeile 39: ihm wird eine Nummer zugewiesen
  • Zeile 40: Ihm wird die auszuführende Methode Lire zugewiesen
  • Zeile 41: Die Methode EndLecteur wird nach Beendigung des Threads ausgeführt
  • Zeile 43: Der Thread wird gestartet
  • Zeilen 47–55: Erstellung der Schreib-Threads
  • Zeile 50: Erstellung des Threads vom Typ MyBackgroundWorker
  • Zeile 51: Ihm wird eine Nummer zugewiesen
  • Zeile 52: Ihm wird die auszuführende Methode Ecrire zugewiesen
  • Zeile 54: Der Thread wird gestartet

Die Methoden Lire und Ecrire bleiben unverändert. Die Methode EndLecteur wird am Ende jedes Lesethreads ausgeführt. Ihr Code lautet wie folgt:


        public static void EndLecteur(object sender, RunWorkerCompletedEventArgs infos) {
            // Inkrementierung der Anzahl der abgeschlossenen Lesevorgänge
            lock (appli) {
                nbLecteursTerminés++;
                if (nbLecteursTerminés == nbThreads)
                    finLecteurs.Set();
            }
}

Die Aufgabe der Methode EndLecteur besteht darin, die Methode Main darüber zu informieren, dass alle Leser ihre Arbeit erledigt haben.

  • Zeile 4: Der Zähler nbLecteursTerminés wird erhöht.
  • Zeilen 5–6: Wenn alle Leser ihre Arbeit erledigt haben, wird das Ereignis finLecteurs auf „wahr“ gesetzt, um die Methode Main zu benachrichtigen, die auf dieses Ereignis wartet.
  • Da die Prozedur EndLecteur von mehreren Threads ausgeführt wird, wird der vorangehende kritische Abschnitt durch die Klausel lock in Zeile 3 geschützt.

Die Ausführung liefert Ergebnisse, die denen der Version mit Threads entsprechen.

10.8.2. Beispiel 2

Der folgende Code veranschaulicht weitere Aspekte der Klasse BackgroundWorker:

  • die Möglichkeit, die Aufgabe abzubrechen
  • die Weiterleitung einer in der Aufgabe ausgelösten Ausnahme
  • die Übergabe eines E/A-Parameters an die Aufgabe

using System;
using System.Threading;
using System.ComponentModel;

namespace Chap8 {
    class Program3 {

        // Threads
        static BackgroundWorker[] tâches = new BackgroundWorker[5];

        public static void Main() {
            // aktuellen Thread initialisieren
            Thread main = Thread.CurrentThread;
            // Thread wird benannt
            main.Name = "Main";

            // Erstellung von Threads
            for (int i = 0; i < tâches.Length; i++) {
                // Thread Nr. i wird erstellt
                tâches[i] = new BackgroundWorker();
                // Initialisierung
                tâches[i].DoWork += Sleep;
                tâches[i].RunWorkerCompleted += End;
                tâches[i].WorkerSupportsCancellation = true;
                // Start des Threads
                tâches[i].RunWorkerAsync(new Data { Numéro = i, Début = DateTime.Now, Durée = i + 1 });
            }
            // Der letzte Thread wird abgebrochen
            tâches[4].CancelAsync();

            // Ende der 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 {
            // Verschiedene Informationen
            public int Numéro { get; set; }
            public DateTime Début { get; set; }
            public int Durée { get; set; }
            public DateTime Fin { get; set; }
        }
    }
}
  • Zeile 9: das Array von BackgroundWorker
  • Zeilen 18–27: Erstellung der Threads
  • Zeile 20: Erstellung des Threads
  • Zeile 22: Der Thread führt die Methode Sleep aus den Zeilen 39–41 aus
  • Zeile 23: Die Methode End aus den Zeilen 43–45 wird am Ende des Threads ausgeführt
  • Zeile 24: Der Thread kann abgebrochen werden
  • Zeile 26: Der Thread wird mit einem Parameter vom Typ [Data] gestartet, der in den Zeilen 49–52 definiert ist. Dieses Objekt verfügt über folgende Felder:
    • Numéro (Eingabe): Nummer des Threads
    • Début (Eingabe): Startzeitpunkt der Thread-Ausführung
    • Durée (Eingabe): Ausführungsdauer von Sleep
    • Fin (Ausgabe): Ende der Ausführung des Threads
  • Zeile 29: Thread Nr. 4 wird abgebrochen

Alle Threads führen die folgende Methode Sleep aus:


        public static void Sleep(object sender, DoWorkEventArgs infos) {
            // Der Parameter „infos“ wird ausgewertet
            Data data = (Data)infos.Argument;
            // Ausnahme bei Aufgabe Nr. 3
            if (data.Numéro == 3) {
                throw new Exception("test....");
            }
            // Ruhezustand für Dauer Sekunden mit einer Unterbrechung alle Sekunden
            for (int i = 1; i <= data.Durée && !tâches[data.Numéro].CancellationPending; i++) {
                // Wartezeit von 1 Sekunde
                Thread.Sleep(1000);
            }
            // Ende der Ausführung
            data.Fin = DateTime.Now;
            // Das Ergebnis wird initialisiert
            infos.Result = data;
            infos.Cancel = tâches[data.Numéro].CancellationPending;
}
  • Zeile 1: Die Methode Sleep hat die Standardsignatur von Ereignisbehandlungsroutinen. Sie erhält zwei Parameter:
    • sender: der Auslöser des Ereignisses, hier das BackgroundWorker, das die Methode
    • infos: vom Typ DoWorkEventArgs, der Informationen über das Ereignis DoWork liefert. Dieser Parameter dient sowohl zur Übermittlung von Informationen an den Thread als auch zum Abrufen seiner Ergebnisse.
  • Zeile 3: Der an die Methode RunWorkerAsync der Aufgabe übergebene Parameter findet sich in der Eigenschaft infos.Argument wieder.
  • Zeilen 5–7: Für Aufgabe Nr. 3 wird eine Ausnahme ausgelöst
  • Zeilen 9–12: Der Thread „schläft“ Durée Sekunden in Intervallen von jeweils einer Sekunde, um den Abbruch-Test in Zeile 9 zu ermöglichen. Dies simuliert einen lang andauernden Vorgang, bei dem der Thread regelmäßig prüfen würde, ob eine Abbruchanforderung vorliegt. Um anzuzeigen, dass er abgebrochen wurde, muss der Thread die Eigenschaft infos.Cancel auf „wahr“ setzen (Zeile 17).
  • Zeile 16: Der Thread kann ein Ergebnis an den Thread zurückgeben, der ihn gestartet hat. Er speichert dieses Ergebnis in infos.Result.

Nach Abschluss ihrer Arbeit führen die Threads die folgende Methode End aus:


public static void End(object sender, RunWorkerCompletedEventArgs infos) {
            // Der Parameter „infos“ wird ausgewertet, um das Ergebnis der Ausführung anzuzeigen
            // Ausnahme?
            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);
                }
        }
  • Zeile 1: Die Methode End hat die Standardsignatur von Ereignisbehandlungsroutinen. Sie erhält zwei Parameter:
    • sender: der Auslöser des Ereignisses, in diesem Fall BackgroundWorker, das die Methode
    • infos: vom Typ RunWorkerCompletedEventArgs, das Informationen zum Ereignis RunWorkerCompleted liefert.
  • Zeile 4: Das Feld infos.Error vom Typ Exception wird nur ausgefüllt, wenn eine Ausnahme aufgetreten ist.
  • Zeile 7: Das Feld infos.Cancelled vom Typ „boolesch“ mit dem Wert true, wenn der Thread abgebrochen wurde.
  • Zeile 8: Wenn keine Ausnahme aufgetreten ist und der Thread nicht abgebrochen wurde, ist infos.Result das Ergebnis des ausgeführten Threads. Die Verwendung dieses Ergebnisses führt zu einer Ausnahme, wenn der Thread abgebrochen wurde oder eine Ausnahme ausgelöst hat. Daher können in den Zeilen 5 und 13 die Nummer des abgebrochenen oder eine Ausnahme auslösenden Threads nicht angezeigt werden, da diese Nummer in infos.Result enthalten ist. Dieses Problem lässt sich umgehen, indem man die Klasse BackgroundWorker ableitet, um dort die Informationen zu speichern, die zwischen dem aufrufenden und dem aufgerufenen Thread ausgetauscht werden sollen, wie es im vorherigen Beispiel geschehen ist. Man verwendet dann das Argument sender, das für BackgroundWorker steht, anstelle des Arguments infos.

Die Ausführungsergebnisse lauten wie folgt:

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. Thread-lokale Daten

10.9.1. Das Prinzip

Betrachten wir eine dreischichtige Anwendung:

Nehmen wir an, es handelt sich um eine Mehrbenutzeranwendung, beispielsweise eine Webanwendung. Jeder Benutzer wird von einem ihm zugewiesenen Thread bedient. Der Lebenszyklus des Threads verläuft wie folgt:

  1. Der Thread wird erstellt oder aus einem Thread-Pool angefordert, um eine Anfrage eines Benutzers zu bearbeiten.
  2. Wenn diese Anfrage Daten erfordert, führt der Thread eine Methode der Schicht [ui] aus, die eine Methode der Schicht [metier] aufruft, die wiederum eine Methode der Schicht [dao] aufruft.
  3. Der Thread gibt die Antwort an den Benutzer zurück. Anschließend wird er beendet oder in einen Thread-Pool zurückgeführt.

Bei Vorgang 2 kann es sinnvoll sein, dass der Thread über eigene Daten verfügt, c.a.d, die nicht mit anderen Threads geteilt werden. Diese Daten könnten beispielsweise zu dem bestimmten Benutzer gehören, den der Thread bedient. Diese Daten könnten dann in den verschiedenen Schichten [ui, metier, dao] verwendet werden.

Die Klasse Thread ermöglicht dieses Szenario dank einer Art privatem Wörterbuch, in dem die Schlüssel vom Typ LocalDataStoreSlot sind:

erstellt einen Eintrag im privaten Wörterbuch des Threads für den Schlüssel name.
ordnet den Wert data dem Schlüssel name im privaten Wörterbuch des Threads zu
ruft den Wert ab, der dem Schlüssel name im privaten Wörterbuch des Threads zugeordnet ist

Ein Anwendungsbeispiel könnte wie folgt aussehen:

  • Um ein Paar (clé,valeur) zu erstellen, das dem aktuellen Thread zugeordnet ist:
Thread.SetData(Thread.GetNamedDataSlot("clé"),valeur);
  • Um den Wert abzurufen, der mit clé verknüpft ist:
Thread.GetData(Thread.GetNamedDataSlot("clé"));

10.9.2. Anwendung des Prinzips

Betrachten wir die folgende dreischichtige Anwendung:

Nehmen wir an, dass die Schicht [dao] eine Artikelstammdatenbank verwaltet und ihre Schnittstelle ursprünglich wie folgt aussieht:


using System.Collections.Generic;

namespace Chap8 {
    public interface IDao {
        int InsertArticle(Article article);
        List<Article> GetAllArticles();
        void DeleteAllArticles();
    }
}
  • Zeile 5: zum Einfügen eines Artikels in die Datenbank
  • Zeile 6: zum Abrufen aller Artikel aus der Datenbank
  • Zeile 7: zum Löschen aller Artikel aus der Datenbank

Später entsteht der Bedarf an einer Methode zum Einfügen einer Artikelliste mithilfe einer Transaktion, da man nach dem Alles-oder-Nichts-Prinzip vorgehen möchte: Entweder werden alle Artikel eingefügt oder keiner. Man kann die Schnittstelle dann anpassen, um diesen neuen Bedarf zu berücksichtigen:


using System.Collections.Generic;

namespace Chap8 {
    public interface IDao {
        int InsertArticle(Article article);
        void insertArticles(Article[] articles);
        List<Article> GetAllArticles();
        void DeleteAllArticles();
    }
}
  • Zeile 6: zum Hinzufügen eines Artikeltabellen in die Datenbank

Später entsteht für eine andere Anwendung der Bedarf, eine in einer Liste gespeicherte Artikelliste zu löschen, ebenfalls im Rahmen einer Transaktion. Es zeigt sich, dass die Schicht [dao] immer umfangreicher wird, um unterschiedlichen geschäftlichen Anforderungen gerecht zu werden. Man kann einen anderen Weg einschlagen:

  • in die Schicht [dao] nur die grundlegenden Operationen InsertArticle, DeleteArticle, UpdateArticle, SelectArticle und SelectArticles
  • die Vorgänge zur gleichzeitigen Aktualisierung mehrerer Artikel in die Schicht [métier] auslagern. Diese würden die elementaren Vorgänge der Schicht [dao] nutzen.

Der Vorteil dieser Lösung besteht darin, dass dieselbe Schicht [dao] unverändert mit verschiedenen Schichten [metier] verwendet werden könnte. Sie bringt jedoch eine Schwierigkeit bei der Verwaltung der Transaktion mit sich, die atomar durchzuführende Aktualisierungen an der Datenbank zusammenfasst:

  • Die Transaktion muss von der Schicht [metier] initiiert werden, bevor diese die Methoden der Schicht [dao] aufruft
  • die Methoden der Schicht [dao] müssen über das Vorhandensein der Transaktion informiert sein, um daran teilzunehmen, falls diese existiert
  • Die Transaktion muss von der Schicht [métier] beendet werden.

Damit die Methoden der Schicht [dao] erkennen, ob möglicherweise eine Transaktion läuft, könnte man die Transaktion als Parameter zu jeder Methode der Schicht [dao] hinzufügen. Dieser Parameter erscheint dann in der Signatur der Methoden der Schnittstelle, wodurch diese an eine bestimmte Datenquelle gebunden wird: die Datenbank. Die lokalen Daten des Threads bieten uns eine elegantere Lösung: Die Schicht [métier] speichert die Transaktion in den lokalen Daten des Threads, und genau dort holt die Schicht [dao] sie ab. Die Signatur der Methoden der Schicht [dao] muss daher nicht geändert werden.

Wir setzen diese Lösung mit dem folgenden Visual Studio-Projekt um:

  • in [1]: die gesamte Lösung
  • in [2]: die verwendeten Referenzen. Da es sich bei der Datenbank [4] um eine SQL Server Compact-Datenbank handelt, ist die Referenz [System.Data.SqlServerCe] erforderlich.
  • in [3]: die verschiedenen Schichten der Anwendung.

Die Datenbank [4] ist die Server Compact-Datenbank SQL, die bereits im vorigen Kapitel, insbesondere in Abschnitt 9.3.1, verwendet wurde.

 

Die Klasse „Article“

Ein Datensatz der oben genannten Tabelle [articles] wird in ein Objekt vom Typ Article gekapselt:


namespace Chap8 {
    public class Article {
        // Eigenschaften
        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; }

        // Konstruktoren
        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ät
        public override string ToString() {
            return string.Format("[{0},{1},{2},{3},{4}]", Id, Nom, Prix, StockActuel, StockMinimum);
        }
    }
}

Schnittstelle der Schicht [dao]

Die Schnittstelle IDao der Schicht [dao] sieht wie folgt aus:


using System.Collections.Generic;

namespace Chap8 {
    public interface IDao {
        int InsertArticle(Article article);
        List<Article> GetAllArticles();
        void DeleteAllArticles();
    }
}
  • Zeile 5: zum Einfügen eines Artikels in die Tabelle [articles]
  • Zeile 6: zum Einfügen aller Zeilen der Tabelle [articles] in eine Objektliste Article
  • Zeile 7: zum Löschen aller Zeilen aus der Tabelle [articles]

Schnittstelle der Schicht [metier]

Die Schnittstelle IMetier der Schicht [metier] sieht wie folgt aus:


using System.Collections.Generic;

namespace Chap8 {
    interface IMetier {
        void InsertArticlesInTransaction(Article[] articles);
        void InsertArticlesOutOfTransaction(Article[] articles);
        List<Article> GetAllArticles();
        void DeleteAllArticles();
    }
}
  • Zeile 5: zum Einfügen einer Artikelgruppe innerhalb einer Transaktion
  • Zeile 6: ebenso, jedoch ohne Transaktion
  • Zeile 7: zum Abrufen der Liste aller Artikel
  • Zeile 8: zum Löschen aller Artikel

Implementierung der Schicht [metier]

Die Fachimplementierung der Schnittstelle IMetier sieht wie folgt aus:


using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;

namespace Chap8 {
    public class Metier : IMetier {
        // Schicht [dao]
        public IDao Dao { get; set; }
        // Verbindungszeichenfolge
        public string ConnectionString { get; set; }

        // Einfügen einer Artikeltabelle in eine Transaktion
        public void InsertArticlesInTransaction(Article[] articles) {
            // Die Verbindung zur Datenbank wird hergestellt
            using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
                // Verbindung wird geöffnet
                connexion.Open();
                // Transaktion
                SqlCeTransaction transaction = null;
                try {
                    // Transaktion starten
                    transaction = connexion.BeginTransaction(IsolationLevel.ReadCommitted);
                    // Die Transaktion wird im Thread gespeichert
                    Thread.SetData(Thread.GetNamedDataSlot("transaction"), transaction);
                    // Artikel werden eingefügt
                    foreach (Article article in articles) {
                        Dao.InsertArticle(article);
                    }
                    // Transaktion wird bestätigt
                    transaction.Commit();
                } catch {
                    // Transaktion wird rückgängig gemacht
                    if (transaction != null)
                        transaction.Rollback();
                }
            }
        }

        // Einfügen einer Artikelliste ohne Transaktion
        public void InsertArticlesOutOfTransaction(Article[] articles) {
            // Einfügen der Artikel
            foreach (Article article in articles) {
                Dao.InsertArticle(article);
            }
        }

        // Artikelübersicht
        public List<Article> GetAllArticles() {
            return Dao.GetAllArticles();
        }
        // Alle Artikel löschen
        public void DeleteAllArticles() {
            Dao.DeleteAllArticles();
        }
    }
}

Die Klasse verfügt über folgende Eigenschaften:

  • Zeile 9: ein Verweis auf die Schicht [dao]
  • Zeile 11: die Verbindungszeichenfolge, über die eine Verbindung zur Artikeldatenbank hergestellt wird

Wir kommentieren hier nur die Methode InsertArticlesInTransaction, da nur diese Schwierigkeiten bereitet:

  • Zeile 16: Es wird eine Verbindung zur Datenbank hergestellt
  • Zeile 18: Die Verbindung wird geöffnet
  • Zeile 23: Eine Transaktion wird angelegt
  • Zeile 25: Sie wird in den lokalen Daten des Threads unter dem Schlüssel „transaction“ gespeichert
  • Zeilen 27–29: Für jeden einzufügenden Artikel wird die Methode zum Einfügen einzelner Datensätze der Schicht [dao] aufgerufen
  • Zeilen 21 und 32: Der gesamte Einfügevorgang des Arrays wird durch einen try/catch-Block überwacht
  • Zeile 31: Wenn diese Zeile erreicht wird, ist keine Ausnahme aufgetreten. Die Transaktion wird dann bestätigt.
  • Zeilen 34–35: Es ist eine Ausnahme aufgetreten, die Transaktion wird rückgängig gemacht
  • Zeile 37: Die Klausel using wird verlassen. Die in Zeile 18 geöffnete Verbindung wird automatisch geschlossen.

Implementierung der Schicht [dao]

Die DAO-Implementierung der Schnittstelle IDao sieht wie folgt aus:


using System.Collections.Generic;
using System.Data;
using System.Data.SqlServerCe;
using System.Threading;

namespace Chap8 {
    public class Dao : IDao {
        // Verbindungszeichenfolge
        public string ConnectionString { get; set; }
        // Abfragen
        public string InsertText { get; set; }
        public string DeleteAllText { get; set; }
        public string GetAllText { get; set; }

        // Implementierung der Schnittstelle

        // Artikel einfügen
        public int InsertArticle(Article article) {
            // Ist gerade eine Transaktion im Gange?
            SqlCeTransaction transaction = Thread.GetData(Thread.GetNamedDataSlot("transaction")) as SqlCeTransaction;
            // Verbindung abrufen oder herstellen
            SqlCeConnection connexion = null;
            if (transaction != null) {
                // Verbindung abrufen
                connexion = transaction.Connection as SqlCeConnection;
            } else {
                // Verbindung herstellen
                connexion = new SqlCeConnection(ConnectionString);
                connexion.Open();
            }
            try {
                // Einfügebefehl vorbereiten
                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;
                // Ausführung
                return sqlCommand.ExecuteNonQuery();
            } finally {
                // Wenn keine Transaktion lief, wird die Verbindung geschlossen
                if (transaction == null) {
                    connexion.Close();
                }
            }
        }

        // Artikelübersicht
        public List<Article> GetAllArticles() {
...
        }

        // Artikel löschen
        public void DeleteAllArticles() {
...
        }
    }
}

Die Klasse verfügt über folgende Eigenschaften:

  • Zeile 9: die Verbindungszeichenfolge, mit der eine Verbindung zur Artikeldatenbank hergestellt wird
  • Zeile 11: der Befehl SQL zum Einfügen eines Artikels
  • Zeile 12: der Befehl SQL zum Löschen aller Artikel
  • Zeile 13: der Befehl SQL zum Abrufen aller Artikel

Diese Eigenschaften werden anhand der folgenden Konfigurationsdatei [App.config] initialisiert:


<?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>

Wir kommentieren die Methode InsertArticle:

  • Zeile 20: Wir rufen die Transaktion ab, die die Schicht [metier] möglicherweise im Thread angelegt hat
  • Zeilen 23–25: Ist die Transaktion vorhanden, wird die Verbindung abgerufen, mit der sie verknüpft wurde.
  • Zeilen 26–30: Andernfalls wird eine neue Verbindung erstellt und geöffnet.
  • Zeilen 33–44: Der Einfügebeleg wird vorbereitet. Dieser wird parametrisiert (siehe Zeile g von App.config).
  • Zeile 33: Das Objekt Command wird angelegt.
  • Zeile 34: Er ist mit der aktuellen Transaktion verknüpft. Wenn diese nicht vorhanden ist (Transaktion = null), entspricht dies der Ausführung des Befehls SQL ohne explizite Transaktion. Es sei daran erinnert, dass in diesem Fall dennoch eine implizite Transaktion vorliegt. Bei SQL Server CE befindet sich diese implizite Transaktion standardmäßig im Modus autocommit: Der Befehl SQL lautet nach seiner Ausführung committé.
  • Zeile 35: Das Objekt Command wird der aktuellen Verbindung zugeordnet
  • Zeile 36: Der auszuführende Text SQl wird festgelegt. Dies ist die parametrisierte Abfrage aus Zeile g von App.config.
  • Zeilen 37–44: Die 4 Parameter der Abfrage werden initialisiert
  • Zeile 46: Die Abfrage wird ausgeführt.
  • Zeilen 49–51: Es ist zu beachten, dass, falls keine Transaktion vorlag, eine neue Verbindung zur Datenbank hergestellt wurde (Zeilen 26–30). In diesem Fall muss sie geschlossen werden. Falls eine Transaktion vorlag, darf die Verbindung nicht geschlossen werden, da sie von der Schicht [metier] verwaltet wird.

Die beiden anderen Methoden greifen das auf, was im Kapitel „Datenbanken“ behandelt wurde:


        // Artikelübersicht
        public List<Article> GetAllArticles() {
            // Artikelübersicht – zunächst leer
            List<Article> articles = new List<Article>();
            // Verbindungsauswertung
            using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
                // Verbindung herstellen
                connexion.Open();
                // führt sqlCommand mit einer SELECT-Abfrage aus
                SqlCeCommand sqlCommand = new SqlCeCommand(GetAllText, connexion);
                using (SqlCeDataReader reader = sqlCommand.ExecuteReader()) {
                    // Auswertung des Ergebnisses
                    while (reader.Read()) {
                        // Auswertung der aktuellen Zeile
                        articles.Add(new Article(reader.GetInt32(0), reader.GetString(1), reader.GetDecimal(2), reader.GetInt32(3), reader.GetInt32(4)));
                    }
                }
            }
            // Das Ergebnis wird zurückgegeben
            return articles;
        }

        // Löschen der Artikel
        public void DeleteAllArticles() {
            using (SqlCeConnection connexion = new SqlCeConnection(ConnectionString)) {
                // Verbindung herstellen
                connexion.Open();
                // führt sqlCommand mit Aktualisierungsanforderung aus
                new SqlCeCommand(DeleteAllText, connexion).ExecuteNonQuery();
            }
}

Die Testanwendung [console]

Die Testanwendung [console] sieht wie folgt aus:


using System;
using System.Configuration;

namespace Chap8 {
    class Program {
        static void Main(string[] args) {
            // Auswertung der Konfigurationsdatei
            string connectionString = null;
            string insertText;
            string getAllText;
            string deleteAllText;
            try {
                // Verbindungszeichenfolge
                connectionString = ConfigurationManager.ConnectionStrings["dbArticlesSqlServerCe"].ConnectionString;
                // weitere Parameter
                insertText = ConfigurationManager.AppSettings["insertText"];
                getAllText = ConfigurationManager.AppSettings["getAllText"];
                deleteAllText = ConfigurationManager.AppSettings["deleteAllText"];
            } catch (Exception e) {
                Console.WriteLine("Erreur de configuration : {0}", e.Message);
                return;
            }
            // Erstellung der Ebene [dao]
            Dao dao = new Dao();
            dao.ConnectionString = connectionString;
            dao.DeleteAllText = deleteAllText;
            dao.GetAllText = getAllText;
            dao.InsertText = insertText;
            // Erstellung einer Ebene [métier]
            Metier metier = new Metier();
            metier.Dao = dao;
            metier.ConnectionString = connectionString;
            // Es wird eine Artikeltabelle angelegt
            Article[] articles = new Article[2];
            for (int i = 0; i < articles.Length; i++) {
                articles[i] = new Article(0, "article", 100, 10, 1);
            }
            // Alle Artikel werden gelöscht
            Console.WriteLine("Suppression de tous les articles...");
            metier.DeleteAllArticles();
            // Die Tabelle wird außerhalb der Transaktion eingefügt
            Console.WriteLine("Insertion des articles hors transaction...");
            try {
                metier.InsertArticlesOutOfTransaction(articles);
            } catch (Exception e){
                Console.WriteLine("Exception : {0}", e.Message);
            }
            // Artikel anzeigen
            Console.WriteLine("Liste des articles");
            AfficheArticles(metier);
            // Alle Artikel werden gelöscht
            Console.WriteLine("Suppression de tous les articles...");
            metier.DeleteAllArticles();
            // Die Tabelle wird in eine Transaktion eingefügt
            Console.WriteLine("Insertion des articles dans une transaction...");
            metier.InsertArticlesInTransaction(articles);
            // Artikel anzeigen
            Console.WriteLine("Liste des articles");
            AfficheArticles(metier);
        }

        private static void AfficheArticles(IMetier metier) {
            // Artikel anzeigen
            foreach(Article article in metier.GetAllArticles()){
                Console.WriteLine(article);
            }
        }

    }
}
  • Zeilen 12–22: Die Datei [App.config] wird verarbeitet.
  • Zeilen 24–28: Die Schicht [dao] wird instanziiert und initialisiert
  • Zeilen 30–32: Dasselbe geschieht für die Ebene [metier]
  • Zeilen 34–37: Es wird ein Array mit zwei Artikeln mit demselben Namen erstellt. Die Tabelle [articles] der Datenbank SQL auf dem Server [dbarticles.sdf] unterliegt einer Eindeutigkeitsbeschränkung für den Namen. Das Einfügen des zweiten Artikels wird daher abgelehnt. Erfolgt das Einfügen des Arrays außerhalb einer Transaktion, wird zunächst der erste Artikel eingefügt und verbleibt anschließend in der Tabelle. Erfolgt das Einfügen des Arrays innerhalb einer Transaktion, wird zunächst der erste Artikel eingefügt und anschließend beim Abbruch der Transaktion wieder entfernt.
  • Zeilen 39–50: Einfügen der Tabelle mit zwei Artikeln außerhalb einer Transaktion und Überprüfung.
  • Zeilen 52–59: dasselbe, jedoch innerhalb einer Transaktion

Die Ergebnisse bei der Ausführung sind wie folgt:

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
  • Zeilen 5–6: Durch das Einfügen außerhalb einer Transaktion ist der erste Artikel in der Datenbank verblieben
  • Zeile 9: Durch das Einfügen innerhalb einer Transaktion wurde kein Artikel in der Datenbank belassen

10.9.3. Fazit

Das vorangegangene Beispiel hat den Nutzen von thread-lokalen Daten für die Transaktionsverwaltung aufgezeigt. Es sollte nicht unverändert nachgebildet werden. Frameworks wie Spring, NHibernate usw. nutzen diese Technik, machen sie jedoch noch transparenter: Die Schicht [metier] kann Transaktionen verwenden, ohne dass die Schicht [dao] davon Kenntnis haben muss. Es gibt also kein Transaction-Objekt im Code der Schicht [dao]. Dies wird mithilfe einer Proxy-Technik namens AOP (Aspect-Oriented Programming) erreicht. Auch hier kann man dem Leser nur empfehlen, diese Frameworks zu verwenden.

10.10. Zur Vertiefung...

Um sich näher mit dem schwierigen Thema der Thread-Synchronisation zu befassen, empfiehlt sich die Lektüre des Kapitels Threading aus dem Buch „C# 3.0“, auf das in der Einleitung dieses Dokuments verwiesen wird. Darin werden zahlreiche Synchronisationstechniken für verschiedene Situationen vorgestellt.