Rheinwerk Computing < openbook > Rheinwerk Computing - Professionelle Bücher. Auch für Einsteiger.
Professionelle Bücher. Auch für Einsteiger.

Inhaltsverzeichnis
Vorwort zur 6. Auflage
1 Allgemeine Einführung in .NET
2 Grundlagen der Sprache C#
3 Das Klassendesign
4 Vererbung, Polymorphie und Interfaces
5 Delegates und Ereignisse
6 Strukturen und Enumerationen
7 Fehlerbehandlung und Debugging
8 Auflistungsklassen (Collections)
9 Generics – Generische Datentypen
10 Weitere C#-Sprachfeatures
11 LINQ
12 Arbeiten mit Dateien und Streams
13 Binäre Serialisierung
14 XML
15 Multithreading und die Task Parallel Library (TPL)
16 Einige wichtige .NET-Klassen
17 Projektmanagement und Visual Studio 2012
18 Einführung in die WPF und XAML
19 WPF-Layout-Container
20 Fenster in der WPF
21 WPF-Steuerelemente
22 Elementbindungen
23 Konzepte von WPF
24 Datenbindung
25 Weitere Möglichkeiten der Datenbindung
26 Dependency Properties
27 Ereignisse in der WPF
28 WPF-Commands
29 Benutzerdefinierte Controls
30 2D-Grafik
31 ADO.NET – Verbindungsorientierte Objekte
32 ADO.NET – Das Command-Objekt
33 ADO.NET – Der SqlDataAdapter
34 ADO.NET – Daten im lokalen Speicher
35 ADO.NET – Aktualisieren der Datenbank
36 Stark typisierte DataSets
37 Einführung in das ADO.NET Entity Framework
38 Datenabfragen des Entity Data Models (EDM)
39 Entitätsaktualisierung und Zustandsverwaltung
40 Konflikte behandeln
41 Plain Old CLR Objects (POCOs)
Stichwort

Download:
- Beispiele, ca. 62,4 MB

Jetzt Buch bestellen
Ihre Meinung?

Spacer
Visual C# 2012 von Andreas Kühnel
Das umfassende Handbuch
Buch: Visual C# 2012

Visual C# 2012
Rheinwerk Computing
1402 S., 6., aktualisierte und erweiterte Auflage 2013, geb., mit DVD
49,90 Euro, ISBN 978-3-8362-1997-6
Pfeil 15 Multithreading und die Task Parallel Library (TPL)
Pfeil 15.1 Überblick
Pfeil 15.2 Multithreading mit der Klasse »Thread«
Pfeil 15.2.1 Einführung in das Multithreading
Pfeil 15.2.2 Threadzustände und Prioritäten
Pfeil 15.2.3 Zusammenspiel mehrerer Threads
Pfeil 15.2.4 Die Entwicklung einer einfachen Multithreading-Anwendung
Pfeil 15.2.5 Die Klasse »Thread«
Pfeil 15.2.6 Threadpools nutzen
Pfeil 15.2.7 Die Synchronisation von Threads
Pfeil 15.2.8 Der »Monitor« zur Synchronisation
Pfeil 15.2.9 Das Attribut »MethodImpl«
Pfeil 15.2.10 Das Synchronisationsobjekt »Mutex«
Pfeil 15.2.11 Grundlagen asynchroner Methodenaufrufe
Pfeil 15.2.12 Asynchroner Methodenaufruf
Pfeil 15.2.13 Asynchroner Aufruf mit Rückgabewerten
Pfeil 15.2.14 Eine Klasse mit asynchronen Methodenaufrufen
Pfeil 15.3 Die TPL (Task Parallel Library)
Pfeil 15.3.1 Allgemeines zur Parallelisierung mit der TPL
Pfeil 15.3.2 Die Klasse »Parallel«
Pfeil 15.3.3 Die Klasse »Task«
Pfeil 15.4 Asynchrone Programmierung mit »async« und »await«

Rheinwerk Computing - Zum Seitenanfang

15.2 Multithreading mit der Klasse »Thread«Zur nächsten Überschrift


Rheinwerk Computing - Zum Seitenanfang

15.2.1 Einführung in das MultithreadingZur nächsten ÜberschriftZur vorigen Überschrift

Stellen Sie sich eine beliebige Anwendung vor, die in der Lage ist, Mails zu versenden. Das Versenden kann, wenn die Mail mehrere größere Anhänge hat, durchaus einen längeren Zeitraum in Anspruch nehmen. Während des Versendens wird eine Sanduhr angezeigt, und der Anwender kann nicht mit der Applikation weiterarbeiten. Erstrebenswert ist es, dem Anwender eine Anwendung auszuliefern, die ohne eine Verzögerung auskommt. Dazu wäre nur eine Applikation in der Lage, die das Multithreading beherrscht. Mit einer solchen Anwendung könnte der Benutzer weiterarbeiten, während (scheinbar) gleichzeitig die Mail verschickt wird.

Die Entgegennahme der Benutzereingabe und das Versenden der Mail sind zwei Operationen, die in einer multithreading-fähigen Anwendung voneinander unabhängig sind und innerhalb eines einzigen Prozesses ablaufen. Beide Operationen können dabei dieselben Daten benutzen. Jeder Operation wird dazu ein eigener Thread zugeordnet. Ein Thread ist eine Ausführungseinheit und besteht aus einer kontinuierlichen Abfolge von Anweisungen. Sie werden weiter unten in den Beispielen noch sehen, wie das zu verstehen ist.

Jeder gestarteten Anwendung ist ein Prozess zugeordnet, in dem mindestens ein Thread existiert. Somit ist ein Thread die kleinste Ausführungseinheit und gehört im Umkehrschluss grundsätzlich immer zu einem Prozess. Wird der letzte Thread eines Prozesses zerstört, wird die Laufzeitumgebung der Anwendung beendet.

Wenn man eine Ein-Prozessor-Maschine voraussetzt, kann zu einem gegebenen Zeitpunkt nur ein Thread von der CPU bearbeitet werden. Stehen mehrere Threads derselben oder auch unterschiedlicher Anwendungen zur Ausführung in einer Warteschlange, erfolgt der Austausch der Threads in der CPU in sehr kleinen Zeitintervallen (Standard: 20 ms). Für die Zuteilung der CPU ist eine Komponente des Systems zuständig, der Scheduler. Einem Anwender fällt das nicht auf – aus seiner Sicht erscheint es so, als würde die Ausführung gleichzeitig erfolgen.

Im Kontext jedes Threads sind alle Informationen enthalten, um die unterbrochene Ausführung zu einem späteren Zeitpunkt wieder problemlos aufnehmen zu können. Dazu gehören die Inhalte der CPU-Register zu dem Zeitpunkt, zu dem der Thread den Prozessor verlassen hat, sowie alle Informationen, die den Zustand des Threads beschreiben.


Rheinwerk Computing - Zum Seitenanfang

15.2.2 Threadzustände und PrioritätenZur nächsten ÜberschriftZur vorigen Überschrift

Wartende, bereite und laufende Threads

Jeder Thread kann sich in einem von drei möglichen Zuständen befinden:

  • wartend (waiting)
  • bereit (ready)
  • laufend (running)

Ein laufender Thread befindet sich aktuell im Prozessor und wird ausgeführt. Ein Prozessor kann zu einem gegebenen Zeitpunkt immer nur einen Thread bearbeiten. Nach Ablauf der zugestandenen Zeitspanne muss der laufende Thread die CPU räumen. Er reiht sich wieder in die Warteschlange auf der Zeitscheibe ein und hofft darauf, dass ihm möglichst schnell wieder Prozessorzeit zugeteilt wird.

Die Threads, die in der Warteschlange stehen, werden als bereit bezeichnet. Nur einem bereiten Thread kann ein Zeitquantum der CPU zugestanden werden.

Es gibt aber auch Threads, die während ihrer Ausführung freiwillig den Prozessor räumen und auch danach zunächst nicht mehr willens sind, sich in die Warteschlange der bereiten Threads einzuordnen. Diese Threads werden als wartend bezeichnet. Ein wartender Thread muss den Anstoß von einem anderen Thread bekommen, um in den bereiten Zustand überführt zu werden.

Bezogen auf einen Prozessor kann sich zu einem gegebenen Zeitpunkt nur ein Thread im laufenden Zustand befinden. Die anderen Threads sind entweder bereit oder wartend. Der aktuell ausgeführte Thread muss unter folgenden Bedingungen den Prozessor aufgeben:

  • Das ihm zugestandene Zeitquantum ist abgelaufen.
  • Der Thread muss auf ein anderes Objekt oder eine Benachrichtigung warten. Er tritt in den Zustand wartend ein.
  • Ein anderer Thread mit einer höheren Priorität befindet sich in der Warteschlange.

Während die beiden zuerst aufgeführten Punkte aufgrund der vorherigen Ausführungen einleuchtend sein sollten, ist der dritte neu.

Threadprioritäten

Jedem Thread wird eine bestimmte Priorität zugeordnet. Stehen mehrere Threads in der Warteschlange, erhält derjenige Thread Prozessorzeit, dessen Priorität am höchsten ist. Windows kennt die Prioritätsstufen 1 bis 31, die allerdings nicht alle genutzt werden. Viele Systemthreads laufen mit einer höheren Priorität als die Threads »normaler« Anwendungen. Der Prioritätszuordnung eines Threads und den daraus resultierenden Konsequenzen bei der Prozessorzuteilung kommt eine sehr große Bedeutung zu. Gäbe es die Zuordnung einer Priorität nicht, wäre es unter anderem nicht möglich, aus einer laufenden Windows-Anwendung heraus eine andere zu aktivieren. Das System fängt nämlich in einem Thread hoher Priorität den Mausklick auf das Fenster einer inaktiven Anwendung ab, ordnet die Mauszeigerposition dem direkt darunter liegenden Fenster zu und aktiviert es.

Die Priorität eines Threads kann bei Bedarf erhöht werden. Damit kann man erreichen, dass Aufgaben, die Vorrang vor anderen haben sollen, nicht gleichberechtigt mit den anderen Threads behandelt werden, sondern bevorzugt. Umgekehrt kann die Priorität eines Threads auch verringert werden, um ihn einerseits bereitzuhalten, ihn aber andererseits nur in bestimmten Situationen zur Ausführung zu bringen, möglicherweise auch erst nach vorheriger Erhöhung der Priorität.

Einen Thread mit niedriger Priorität haben Sie schon kennengelernt: Es ist der Thread des Garbage Collectors. Dieser erhält erst dann Prozessorzeit, wenn keine andere Ausführungseinheit die CPU beansprucht oder die Ressourcen knapp werden. Tritt die letztgenannte Situation ein, wird die Priorität des Garbage Collectors angehoben, damit die Speicherbereinigung ihre Arbeit verrichten kann.

Ein Thread, dessen Zeit auf der Zeitscheibe abgelaufen ist, wird als bereit markiert und sichert den Zustand der Daten im Stack. Danach sucht das Betriebssystem nach dem Thread, der sowohl als bereit markiert ist als auch gleichzeitig die höchste Priorität besitzt. Befinden sich mehrere bereite Threads auf derselben Prioritätsstufe, weist der Scheduler dem in der Reihenfolge stehenden Thread den Prozessor zu.


Rheinwerk Computing - Zum Seitenanfang

15.2.3 Zusammenspiel mehrerer ThreadsZur nächsten ÜberschriftZur vorigen Überschrift

Eine multithreading-fähige Anwendung zu schreiben ist nicht einfach und birgt immer die latente Gefahr von Fehlern. Eine der größten Fehlerquellen ist der gleichzeitige Zugriff mehrerer Komponenten auf eine gemeinsame Ressource. Wartet zum Beispiel ausnahmslos jede der beteiligten Komponenten auf die Antwort der anderen, wäre ein Deadlock die Folge – die Anwendung kann nicht mehr weiterarbeiten und hängt sich auf. Wir können in diesem Kapitel nicht alle Aspekte erörtern, die in diesem Zusammenhang von Bedeutung sind. Sie sollten aber immer daran denken, dass mit steigender Anzahl der Threads das Gefahrenpotenzial exponentiell steigt, die Komplexität der Anwendung drastisch zunimmt und der Ablauf der Anwendung nur noch schwierig nachzuvollziehen ist.

Trotz der Schwierigkeiten, die sich bei der Entwicklung einer Multithreading-Anwendung ergeben, gibt es eine Reihe von Situationen, in denen der Einsatz mehrerer Threads sinnvoll ist. Meistens handelt es sich um Operationen, die generell eine längere Zeitspanne für ihre Ausführung benötigen, z. B. bei der Kommunikation mit anderen Rechnern über das Netzwerk oder bei Tasks, die als zeitkritisch eingestuft werden müssen.


Rheinwerk Computing - Zum Seitenanfang

15.2.4 Die Entwicklung einer einfachen Multithreading-AnwendungZur nächsten ÜberschriftZur vorigen Überschrift

Im folgenden Beispiel wird auf einfachste Weise neben dem Hauptthread, der beim Starten einer Anwendung automatisch erzeugt wird, ein zweiter Thread per Programmcode ins Leben gerufen. Anhand dieses kleinen Programms wollen wir uns mit den wichtigsten Grundlagen einer multithreading-fähigen Anwendung vertraut machen.

// Beispiel: ..\Kapitel 15\EinfacherThread
class Program {
static void Main(string[] args) {
ThreadStart del;
del = new ThreadStart(TestMethod);
Thread thread = new Thread(del);
// den zweiten Thread starten
thread.Start();
for(int i = 0; i <= 100; i++) {
for(int k = 1; k <= 20; k++)
Console.Write(".");
Console.WriteLine("Primär-Thread " + i);
}
Console.ReadLine();
}
// diese Methode wird in einem eigenen Thread ausgeführt
public static void TestMethod() {
for(int i = 0; i <= 100; i++) {
for(int k = 1; k <= 20; k++)
Console.Write("X");
Console.WriteLine("Sekundär-Thread " + i);
}
}
}

Listing 15.1 Einen einfachen Thread erzeugen

Alle Klassen, die mit der Entwicklung multithreading-fähiger Anwendungen unter .NET in Zusammenhang stehen, sind im Namespace System.Threading zu finden, der am Anfang des Programms mit using bekannt gegeben werden sollte. Die wichtigste Klasse innerhalb dieses Namespaces dürfte die Klasse Thread sein, mit der ein neuer Thread erzeugt wird. Werfen wir einen Blick auf den eingesetzten Konstruktor dieser Klasse:

public Thread(ThreadStart start); 

Bei dem Parameter vom Typ ThreadStart handelt es sich um einen Delegate, der die Methode angibt, deren Anweisungen in einem neuen Thread ausgeführt werden sollen. Die Definition dieses Delegates lautet wie folgt:

public sealed delegate void ThreadStart();

Die Instanz eines Delegates kapselt den Zeiger auf die Speicheradresse einer Methode. Die Typen der Parameterliste des Delegates müssen den Typen der Parameterliste der Methode entsprechen, auf die der Delegate verweist. Demzufolge kann man dem Konstruktor der Klasse Thread über den Delegate nur die Adresse einer parameterlosen Methode zuweisen – in unserem Beispiel ist es die Methode TestMethod:

ThreadStart del = new ThreadStart(TestMethod);
Thread thread = new Thread(del);

Im ersten Schritt wird die Variable del vom Typ des Delegates ThreadStart deklariert. Dem Delegate wird die Adresse der benutzerdefinierten Methode übergeben. Danach kann die Thread-Klasse unter Übergabe der Referenz des Delegates instanziiert werden. Mit

Thread thread = new Thread(new ThreadStart(TestMethod));

können Sie den Code auch einzeilig formulieren, da die Referenz auf den Delegate nicht mehr benötigt wird.

Die Instanziierung der Thread-Klasse ist noch nicht ausreichend, um den zweiten Thread der Anwendung zu aktivieren. Entscheidend ist vielmehr die Methode Start des Thread-Objekts:

thread.Start();

Mit dem Start der Anwendung wird bereits der erste Thread, der Primärthread, automatisch erstellt. Der zweite Thread wird erst mit Aufruf von Start auf die Thread-Referenz zum Leben erweckt. In dem neuen Thread wird TestMethod ausgeführt.

Beide Threads arbeiten zwei verschachtelte Schleifen ab. Die Schleifen sind so konstruiert, dass eine Zeitscheibeneinheit nicht ausreicht, um jeweils vollständig die Schleifen zu durchlaufen, denn dann könnten wir den Effekt des Multithreadings an der Konsole nicht erkennen. In der inneren Schleife wird eine Ausgabe in die Konsole geschrieben. Beim Primärthread handelt es sich um 20 Punkte pro Schleifendurchlauf, beim Sekundärthread sind es jeweils 20-mal der Buchstabe »X«. Daran schließt sich noch die Angabe an, welcher Thread für die Ausgabe verantwortlich ist. Darüber hinaus wird der aktuelle Zählerstand der äußeren Schleife angehängt.

Schauen wir uns nun die Ausgabe an, die abhängig von der Hardwareausstattung, der Systemkonfiguration und anderen laufenden Anwendungen durchaus anders aussehen kann. Die Interpretation der Ausgabe hilft, die Arbeitsweise der Threads im Zusammenhang mit der Zeitscheibe und der quasiparallelen Ausführung zu verstehen. Auch wenn die Anzeige ziemlich chaotisch anmutet, am Ende werden beide Threads ihre Aufgabe vollständig erledigt haben.

Abbildung

Abbildung 15.1 Die Ausgabe der Anwendung »EinfacherThread«

Der Delegate »ParameterizedThreadStart«

Den Delegate ThreadStart, mit dem die in einem separaten Thread laufende Methode beschrieben wird, haben wir im vorigen Abschnitt behandelt. ThreadStart hat jedoch ein Manko, denn diese Methode muss parameterlos sein. Manchmal ist es aber notwendig, der Threadmethode Daten zu übergeben. Dazu wird uns eine Alternative mit dem Delegate ParameterizedThreadStart geboten:

public sealed delegate void ParameterizedThreadStart(object obj);

Die Instanz eines solchen Delegates kapselt den Zeiger auf eine Methode, die die Referenz auf ein beliebiges Objekt erwartet. Hier werden uns alle Türen geöffnet, denn wir können, falls mehrere Daten an die Methode übergeben werden sollen, auch ein Array oder eine Auflistung angeben.

Die Erzeugung des Threads erfolgt in bekannter Weise. Der einzige Unterschied ist im Konstruktor der Klasse Thread zu finden, dem wir eine Instanz des Delegaten vom Typ ParameterizedThreadStart übergeben:

Thread thread = new Thread(new ParameterizedThreadStart(ThreadMethod));

Um die gewünschten Daten an die Threadmethode zu leiten, greifen wir auf eine Überladung der Start-Methode zu, der wir das entsprechende Argument mitteilen:

thread.Start(IrgendEinObjekt);

Das übergebene Objekt enthält die Daten, die von der vom Thread ausgeführten Methode verwendet werden sollen.


Rheinwerk Computing - Zum Seitenanfang

15.2.5 Die Klasse »Thread«Zur nächsten ÜberschriftZur vorigen Überschrift

Zugriff eines Threads auf sich selbst

Ein Thread wird erzeugt, wenn die Klasse Thread unter Übergabe eines Delegates instanziiert wird. Dies stellt nicht die einzige Möglichkeit dar, sich die Referenz auf einen Thread zu besorgen. Wenn es beispielsweise notwendig ist, auf dem Hauptthread Operationen auszuführen, steht Ihnen diese Referenz explizit nicht zur Verfügung, da der Thread implizit beim Start der Anwendung erzeugt wird. Abhilfe schafft die statische Eigenschaft CurrentThread, die eine Referenz auf den aktuellen Thread liefert. Nehmen wir an, dass ein Thread seine eigene Priorität mit der Eigenschaft Priority erhöhen soll. Dann müssten Sie

Thread.CurrentThread.Priority = Prioritätswert;

codieren, damit der Thread auf sich selbst zugreifen kann. Auf die Eigenschaft Priority kommen wir später zu sprechen.

Einen Thread für eine bestimmte Zeitdauer anhalten

Im Beispiel EinfacherThread wurde eine Schleife konstruiert, um eine kleine Zeitverzögerung zu erreichen. Ohne Schleife könnte es sein, dass die gesamte Schleife des ersten Threads bereits vollständig abgearbeitet ist, bevor der zweite Thread zum ersten Mal in seine eigene Schleife eintritt. Die Thread-Klasse bietet für solche Fälle mit der Methode Sleep eine bessere Alternative, um einen Thread für eine bestimmte Zeitdauer anzuhalten und damit die Ausführung zu verzögern.

Die Methode ist statisch definiert und kann nicht auf eine bestimmte Threadinstanz aufgerufen werden. Der aktuelle Thread zieht sich damit selbst aus dem Verkehr. Sleep ist übrigens unabhängig von der Taktfrequenz des Computers. Wird Sleep die Zahl 0 übergeben, wird der Thread dazu veranlasst, auf den verbleibenden Rest seiner Ausführungszeit zu verzichten und die CPU für den nächsten anstehenden Thread frei zu machen. Er reiht sich danach sofort wieder in die Warteschlange ein.

Sicheres Beenden eines Threads

Mit der Methode Abort lässt sich ein Thread terminieren. Der Aufruf bewirkt in der Laufzeitschicht die Auslösung der Ausnahme ThreadAbortException. Damit ist es möglich, die Methode ordnungsgemäß zu beenden, beispielsweise um dabei offene Ressourcen zu schließen.

Dazu zunächst ein Beispiel. Diesmal wird die Routine, die in einem zweiten Thread ausgeführt wird, in einer eigenen Klasse definiert. Damit ändert sich grundsätzlich nichts, da dem Delegate nun die Adresse der Instanzmethode in der Klasse mitgeteilt wird.

// Beispiel: ..\Kapitel 15\AbortThread
class Program {
static void Main(string[] args) {
Demo obj = new Demo();
ThreadStart del = new ThreadStart(obj.TestMethod);
Thread thread = new Thread(del);
Console.WriteLine("Thread wird jetzt gestartet");
// Sekundärthread starten
thread.Start();
Console.WriteLine("Thread ist gestartet");
Thread.Sleep(200);
// der sekundäre Thread wird durch den Primärthread mit
// der Methode Abort zerstört
thread.Abort();
Thread.Sleep(100);
if (thread.IsAlive)
Console.WriteLine("Der Sek.-Thread lebt noch");
else
Console.WriteLine("Der Sek.-Thread ist aufgegeben");
Thread.Sleep(5000);
}
}
class Demo {
public void TestMethod() {
try
{
Console.WriteLine("Sek.-Thread gestartet.");
// die Schleife zwingt Thread eine länger dauernde Ausführung auf
for(int i = 0; i <= 100; i++) {
Console.WriteLine("Sek.-Thread-Zähler = {0}", i);
Thread.Sleep(50);
}
}
catch (ThreadAbortException ex)
{
Console.WriteLine("Sek.-Thread/im Catch-Block");
}
finally {
Console.WriteLine("Sek.-Thread/in Finally");
}
Console.WriteLine("Sek.-Thread/nach Finally");
for (int i = 0; i <= 20; i++) {
Console.Write(".");
Thread.Sleep(50);
}
}
}

Listing 15.2 Das Beenden eines Threads

Nach dem Instanziieren der Thread-Klasse wird der zweite Thread gestartet. Da wir die Abort-Methode testen wollen, müssen wir dafür sorgen, dass Abort nicht auf einen Thread trifft, der nicht mehr ausgeführt wird. Deshalb ist in ThreadExecution der Klasse Demo eine Schleife eingebaut, die eine längere Zeit für einen vollständigen Durchlauf benötigt. Die Zeit muss so lang angesetzt werden, dass Abort auf die noch in Arbeit befindliche Schleife trifft.

Vor dem Aufruf von Abort wird der Primärthread zunächst mit Sleep gebremst, damit der Sekundärthread etwas Zeit zu arbeiten hat. Nach dem Aufruf von Abort bekommt das System mit einem zweiten Sleep-Aufruf noch Zeit, den Sekundärthread endgültig zu beenden. Durch Auswertung der Eigenschaft IsAlive auf dem Sekundärthread wird festgestellt, ob dieser noch aktiv ist oder nicht. Würden wir dem Hauptthread keine Ruhepause gönnen, könnte eine falsche Aussage die Folge sein, da die if-Bedingungsprüfung vor der Aufgabe des Sekundärthreads durchgeführt wird, weil sich Abort und if innerhalb derselben Zeitscheibe befinden und der freigegebene Thread noch keine Möglichkeit erhalten hat, die Ausnahme auszulösen. Die zweite Schleife in der Methode TestMethod der Klasse Demo soll ebenfalls eine länger andauernde Operation simulieren.

An der Konsole erfolgt die folgende Ausgabe:

Thread wird jetzt gestartet
Thread ist gestartet
Sek.-Thread gestartet
Sek.-Thread-Zähler = 0
Sek.-Thread-Zähler = 1
Sek.-Thread-Zähler = 2
Sek.-Thread-Zähler = 3
Sek.-Thread/in Catch-Block
Sek.-Thread/in Finally
Der Sek.-Thread ist aufgegeben

Hier ergibt sich anscheinend ein Widerspruch zu der Aussage in Kapitel 7, dass die hinter finally stehenden Anweisungen ausgeführt werden: Der Aufruf von Abort löst die Exception ThreadAbortException aus, aber die zweite Schleife im Sekundärthread wird nicht mehr durchlaufen. Genau in diesem Punkt liegt das Besondere dieser Ausnahme, denn sie wird ausgelöst und auch abgefangen, aber die Anweisungen hinter dem Ende der Ausnahmebehandlung kommen nicht mehr zur Ausführung, da der Thread in diesem Moment bereits terminiert ist. Allerdings unterstützt die Laufzeitschicht abschließende Anweisungen in finally.

Gegen das außerplanmäßige Beenden kann sich der betroffene Thread allerdings auch zur Wehr setzen. Dazu muss im catch-Block des Exception Handlers die statische Methode ResetAbort aufgerufen werden:

[...]
catch (ThreadAbortException e) {
Thread.ResetAbort();
Console.WriteLine("Sek.-Thread/im Catch-Block");
[...]
}

Listing 15.3 Einsatz der Methode »ResetAbort«

Bauen Sie diese Anweisung in den Programmcode des Beispiels ein, wird auch die zweite Schleife in TestMethod ausgeführt, und die bedingte Prüfung mit if führt zu dem Ergebnis, dass der Thread noch lebt – das allerdings auch nur, weil die zweite Schleife ebenfalls wieder eine längere Zeit in Anspruch nimmt oder der Thread nicht schon auf normalem Wege aufgegeben worden ist, bevor die Prüfung erfolgt.

Abhängige Threads – die Methode »Join«

Nun wäre die folgende Ausgangssituation vorstellbar: Der Primärthread beendet den Sekundärthread mit Abort und muss dabei sicherstellen, dass die Anweisungen im Sekundärthread zuerst vollständig abgearbeitet sind, bevor die nächste Anweisung im Primärthread ausgeführt wird. Solche Situationen können auftreten, wenn der Code des Primärthreads auf das ordnungsgemäße Beenden angewiesen ist. Das heißt aber auch, dass der Aufruf synchron erfolgen muss – dass also auf die quasigleichzeitige Ausführung, die ansonsten die Threads auszeichnet, bewusst verzichtet wird.

Wir wollen, um uns der Problematik bewusst zu werden, zunächst eine kleine Änderung in Main vornehmen. Die Implementierung der Klasse Demo bleibt wie im Beispiel AbortThread erhalten (also ohne den Aufruf von ResetAbort, falls Sie damit experimentiert haben sollten).

static void Main(string[] args) {
Demo obj = new Demo();
ThreadStart firstThread;
firstThread = new ThreadStart(obj.TestMethod);
Thread TheThread = new Thread(firstThread);
Console.WriteLine("Thread wird jetzt gestartet");
// sekundären Thread starten
TheThread.Start();
Console.WriteLine("Thread ist gestartet");
Console.WriteLine("vor Abort.............");
// der sekundäre Thread wird durch den Primärthread mit
// der Methode Abort zerstört
Thread.Sleep(200);
TheThread.Abort();
// die folgende Anweisung simuliert Code, der vom Beenden
// des Sekundärthreads abhängig ist
Console.WriteLine("nach Abort.............");
Thread.Sleep(100);
if (TheThread.IsAlive)
Console.WriteLine("Der Sek.-Thread lebt noch");
else
Console.WriteLine("Der Sek.-Thread ist aufgegeben");
Thread.Sleep(5000);
}

Listing 15.4 Änderung des Beispielprogramms »ThreadAbort«

Main enthält eine Anweisung, die nach dem Aufruf der Abort-Methode die Konsolenausgabe

nach Abort................

erzwingt. Damit sollen Anweisungen simuliert werden, die auf das ordnungsgemäße Terminieren des sekundären Threads angewiesen sind. Sehen wir uns die Konsolenausgabe des Programmcodes in Abbildung 15.2 an.

Deutlich ist zu erkennen, dass der sekundäre Thread nach Abort immer noch aktiv ist – die catch- und finally-Blöcke werden nach der abhängigen Anweisung ausgeführt.

Abbildung

Abbildung 15.2 Abhängige Threads – unerwünschter Programmfluss

Jetzt hilft eine andere Methode der Klasse Thread weiter: Join, die den aktuellen, also aufrufenden Thread so lange blockiert, bis der Sekundärthread vollständig terminiert ist. Sinnvollerweise wird Join direkt hinter Abort aufgerufen. Der Programmablauf kehrt erst dann zum Aufrufer zurück, wenn die Threadausführung ordnungsgemäß beendet ist.

// Beispiel: ..\Kapitel 15\AbhängigerThread
class Program {
static void Main(string[] args) {
[...]
// der sekundäre Thread wird durch den Primärthread mit
// der Methode Abort zerstört
Thread.Sleep(200);
TheThread.Abort();
TheThread.Join();
// die folgende Anweisung simuliert Code, der vom
// Beenden des Sekundärthreads abhängig ist
Console.WriteLine("nach Abort.............");
[...]
}
}
[...]

Listing 15.5 Abhängige Threads

Wenn Sie dieses Programm starten, gibt die Konsole das in Abbildung 15.3 gezeigte Ergebnis aus.

Abbildung

Abbildung 15.3 Ausgabe nach dem sicheren Beenden des Threads

Wenn wir diese Ausgabe mit der vergleichen, die wir ohne Join hatten (Abbildung 15.2), können wir eindeutig erkennen, dass der Thread, dessen Terminierung angestoßen wurde, zuerst vollständig abgearbeitet wird, bevor der Aufrufer seinen eigenen Programmfluss fortsetzt.

Threadprioritäten festlegen

Jeder Thread hat eine Priorität. Mit der Eigenschaft Priority lässt sich die Priorität eines Threads erhöhen, verringern oder einfach nur auswerten. Die Priorität spielt eine entscheidende Rolle bei der Vergabe der Zeitscheibe: Ein Thread hat Vorrang vor einem anderen Thread mit niedrigerer Priorität – vorausgesetzt natürlich, dass sich beide durch den Zustand bereit beschreiben lassen.

Priority ist vom Typ der Enumeration ThreadPriority, die fünf Member definiert: Highest, AboveNormal, Normal, BelowNormal, Lowest.

Die Prioritäten können von der höchsten Stufe (Threadpriority.Highest) bis zur niedrigsten (ThreadPriority.Lowest) eingestellt werden. Die automatisch einem Thread zugewiesene Priorität lautet Normal.

Der Thread mit der höchsten Priorität erhält die Zeitscheibe und läuft so lange, bis er sich selbst mit Sleep einfriert, seine Operationen beendet sind, Abort auf ihm aufgerufen wird oder bis ein Thread höherer Priorität lauffähig ist und Anspruch auf die CPU erhebt.

Am häufigsten ist der Fall anzutreffen, dass sich mehrere Threads gleicher Priorität in die Warteschlange zur CPU eingeordnet haben. Alle erhalten gleiche Zeitanteile der CPU nach einem Verfahren, das als Round-Robin-Verteilungsverfahren bezeichnet wird. Im folgenden Beispielprogramm wollen wir die Auswirkungen der Prioritätsfestlegung in einer Anwendung studieren.

// Beispiel: ..\Kapitel 15\ThreadPriorität
class Program {
// Starten des primären Threads
static void Main(string[] args) {
Demo obj = new Demo();
Thread thread1, thread2;
thread1 = new Thread(new ThreadStart(obj.Execution1));
thread2 = new Thread(new ThreadStart(obj.Execution2));
// die Priorität von thread1 hochsetzen
thread1.Priority = ThreadPriority.AboveNormal;
// thread 1 starten
thread1.Start();
// thread 2 starten
thread2.Start();
Console.ReadLine();
}
}
class Demo {
public void Execution1() {
for (int i = 0; i <= 500; i++) {
Console.Write(".");
}
}
public void Execution2() {
for (int number = 0; number <= 10; number++)
Console.WriteLine("It's me,Thread2");
}
}

Listing 15.6 Festlegen von Threadprioritäten

Um den Unterschied deutlich zu machen, empfiehlt es sich, beim ersten Versuch die Anweisung zur Erhöhung der Priorität des ersten Threads auszukommentieren. Wenn Sie mit dieser Vorgabe die Laufzeitumgebung starten, werden Sie eine Konsolenausgabe erhalten (siehe Abbildung 15.4). thread1 wird gestartet, schreibt ein paar Punkte in die Ausgabe und übergibt danach dem Prozessor den thread2, der sich durch eine eigene Zeichenfolge bemerkbar macht. Die Zeitscheibe dauert lange genug, um die Anweisungen von thread2 vollständig zu bearbeiten. Danach übernimmt wieder thread1 die CPU und beendet seine Ausführung.

Einem Thread eine gewisse Sonderstellung durch die Erhöhung der Priorität einzuräumen, mag vielleicht manchmal ganz verlockend klingen. Bedenken Sie jedoch, dass dieser Thread bei einer lang andauernden Operation eine bremsende Wirkung auf die anderen Threads hat. Man spricht auch von einem Aushungern des Systems. Gehen Sie daher sorgfältig mit dem Erhöhen von Prioritäten um, und achten Sie darauf, dass keine unnötigen Operationen von einem solchen Thread ausgeführt werden, sondern nur solche, die für den weiteren Ablauf der Anwendung unbedingt notwendig sind.

Abbildung

Abbildung 15.4 Ausgabe des Beispielprogramms »ThreadPriorität«

Vorder- und Hintergrundthreads

Threads werden in zwei Kategorien unterteilt: in Vorder- und in Hintergrundthreads. Ein Prozess wird ausgeführt, solange noch mindestens ein Vordergrundthread existiert. Mit dem Beenden des letzten Vordergrundthreads wird der Prozess der Anwendung selbst dann beendet, wenn Hintergrundthreads noch aktiv sind und die ihnen auferlegte Aufgabe noch nicht vollständig ausgeführt haben. Das Beenden eines Hintergrundthreads hat im umgekehrten Fall aber nicht zur Konsequenz, dass der Vordergrundthread beendet wird.

Die Eigenschaft IsBackground beschreibt, ob ein Thread als Vorder- oder Hintergrundthread eingestuft ist. Grundsätzlich sind alle Threads, die aus der Klasse Thread erzeugt werden, zunächst Vordergrundthreads. Mit IsBackground lässt sich ein Thread aber auch zu einem Hintergrundthread degradieren.


Rheinwerk Computing - Zum Seitenanfang

15.2.6 Threadpools nutzenZur nächsten ÜberschriftZur vorigen Überschrift

Die Arbeit mit mehreren Threads lässt sich durch Threadpools wesentlich vereinfachen, denn die Laufzeitumgebung erzeugt eine bestimmte Anzahl von Threads, wenn sie gestartet wird. Sie können diese Threads nutzen und brauchen nicht eigens neue zu erzeugen, wenn Sie welche benötigen. Nach der Beendigung einer Threadmethode wird der frei gewordene Thread in den Pool zurückgeführt und steht anderen Aufgaben zur Verfügung.

Angesprochen wird der Threadpool mit der gleichnamigen Klasse Threadpool. Mit deren statischer Methode QueueUserWorkItem wird der Threadpool aktiviert. Dabei wird der Methode ein Delegate vom Typ WaitCallback übergeben, der die Methode beschreibt, die mit dem Thread ausgeführt werden soll.

Grau ist alle Theorie, daher sehen wir uns zuerst ein komplettes Beispiel an.

// Beispiel: ..\ Kapitel 15\ThreadpoolSample
class Program {
static void Main(string[] args) {
// den Threadpool erforschen
int maxThreads;
int asyncThreads;
ThreadPool.GetMaxThreads(out maxThreads, out asyncThreads);
Console.WriteLine("Max. Anzahl Threads: {0}", maxThreads);
Console.WriteLine("Max. Anzahl E/A-Threads: {0}", asyncThreads);
Console.WriteLine(new string('-', 40));
// Benachrichtigungsereignis, Zustand 'nicht signalisieren'
AutoResetEvent ready = new AutoResetEvent(false);
// Anfordern eines Threads aus dem Pool
ThreadPool.QueueUserWorkItem(new WaitCallback(Calculate), ready);
Console.WriteLine("Der Hauptthread wartet ...");
// Hauptthread in den Wartezustand setzen
ready.WaitOne();
Console.WriteLine("Sekundärthread ist fertig.");
Console.ReadLine();
}
public static void Calculate(object obj) {
Console.WriteLine("Im Sekundärthread");
Thread.Sleep(5000);
// Ereigniszustand auf 'signalisieren' festlegen
((AutoResetEvent)obj).Set();
}
}

Listing 15.7 Nutzen des Threadpools

Die Methode Calculate soll in einem Thread aus dem Threadpool ausgeführt werden. Bevor diese Operation eingeleitet wird, wollen wir aber noch feststellen, wie viele Threads uns der Pool zur Verfügung stellt, und rufen dazu die statische Methode GetMaxThreads auf. Über den ersten Parameter werden uns die Threads geliefert, der zweite Parameter gibt darüber hinaus Auskunft über die maximale Anzahl der möglichen E/A-Anforderungen. Sie werden feststellen, dass sich 25 Threads im Pool befinden, und zwar pro Prozessor.

Das Beispiel ist so entwickelt, dass nicht nur ein Thread aus dem Pool zur Ausführung der Methode Calculate herangezogen wird. Darüber hinaus wird auch ein Synchronisationsszenario in Gang gesetzt, das bewirkt, dass während der Ausführung von Calculate der aufrufende Code in Wartestellung versetzt wird und auf ein Signal von Calculate wartet, bevor er seine Arbeit wieder aufnimmt. Mehr zur Synchronisierung erfahren Sie im folgenden Abschnitt.

Dem Aufruf der statischen Methode QueueUserWorkItem wird ein Delegate übergeben, der die Methode beschreibt, die im Thread ausgeführt werden soll. Darüber hinaus kann QueueUserWorkItem ein zweites Argument übergeben werden, um der Threadmethode Daten bereitzustellen. Hier wird dem zweiten Parameter ein Objekt vom Typ AutoResetEvent übergeben. Dieses Objekt versetzt zwei Threads in die Lage, über Signale miteinander zu kommunizieren. Erzeugt wird das Objekt im Code mit:

AutoResetEvent ready = new AutoResetEvent(false);

Der Übergabeparameter false besagt, dass der anfängliche Zustand des Objekts auf »nicht signalisiert« festgelegt wird. Mit

ready.WaitOne();

wird der aktuelle Thread so lange blockiert, bis er ein Signal erhält. Dieses stammt aus der Threadmethode und wird durch Aufruf der Set-Methode des AutoResetEvent-Objekts ausgelöst:

((AutoResetEvent)obj).Set();

Hier profitieren wir davon, dass wir der Threadmethode im zweiten Parameter die Referenz auf das AutoResetEvent übergeben haben.


Rheinwerk Computing - Zum Seitenanfang

15.2.7 Die Synchronisation von ThreadsZur nächsten ÜberschriftZur vorigen Überschrift

Solange nur ein Thread eine bestimmte Methode aufruft, hat man die Garantie, dass der Code von der ersten bis zur letzten Anweisung durchlaufen wird. Sind mehrere Threads im Spiel, könnte einer der Threads eine Methode in einem ungültigen Zustand hinterlassen, wenn das System ihm die Zeitscheibe quasi mitten in der Ausführung der Methode entzieht und der nächste Thread mit derselben Methode auf demselben Objekt zu arbeiten beginnt. Der Thread, der den Objektzustand von seinem Vorgänger übernommen hat, produziert dann möglicherweise Ergebnisse, die nicht vorhersehbar und in der Regel auch falsch sind.

Hier kommt ein neuer Begriff ins Spiel, der Ihnen vielfach in der Dokumentation zur .NET-Klassenbibliothek begegnen wird: die Threadsicherheit.

Unter Threadsicherheit versteht man, dass ein Objekt auch dann in einem gültigen Zustand bleibt, wenn mehrere Threads gleichzeitig auf dieselbe Ressource zugreifen. Threadsicher bedeutet nichts anderes, als dass mehrere Threads gleichzeitig dieselbe Methode desselben Objekts aufrufen dürfen, ohne dass es zu Konflikten kommt.

Unsynchronisierte Threads

Bevor wir uns mit den Details der Threadsicherheit beschäftigen, wollen wir uns an einem Beispiel verdeutlichen, was unter einem ungültigen Zustand zu verstehen ist und welche Auswirkungen das haben kann.

class Program {
static void Main(string[] args) {
Demo obj = new Demo();
Thread thread1, thread2;
thread1 = new Thread(new ThreadStart(obj.Worker));
thread2 = new Thread(new ThreadStart(obj.Worker));
thread1.Start();
thread2.Start();
Console.ReadLine();
}
}
class Demo {
private int value;
public void Worker() {
while(true) {
value++;
if (value > 100) break;
Console.WriteLine(value);
}
}
}

Listing 15.8 Unsynchronisierte Threads

Das Projekt enthält zusätzlich zu Main noch die Definition der Methode Worker in Demo. In Main werden zwei Threads konstruiert, die beide die Methode Worker aufrufen. Worker selbst durchläuft eine Schleife, in der die Variable value hochgezählt und der aktuelle Inhalt an der Konsole ausgegeben wird. Mit dem Endwert von 100 wird die Methode wieder verlassen.

Beide Threads greifen auf dasselbe Objekt zu und teilen sich die Arbeit mehr oder weniger abwechselnd, um das Feld value hochzuzählen und dessen Inhalt anzuzeigen. Eigentlich sollte man erwarten, dass die Zahlen chronologisch hintereinander ausgegeben werden, jedoch kommt es an der Konsole beispielsweise zu folgender Ausgabe: 1, 2, 3, 4, ... , 39, 41, 42, 43, ..., 99, 100, 40. Beide Threads greifen unsynchronisiert auf die Variable value zu, wobei die Operation des ersten Threads mitten in der Schleife unterbrochen wird. Dieses ist dem Anschein nach genau der Moment, nachdem der Feldinhalt mit der Anweisung

value++;

zwar schon auf 40 erhöht, aber mit

Console.WriteLine(value);

noch nicht an der Konsole ausgegeben wurde. Der unterbrochene Thread weiß natürlich genau, mit welcher Anweisung er seine Arbeit wieder aufnehmen muss, wenn ihm der Scheduler wieder Prozessorzeit zuteilt: Er muss zuerst die Zahl 40 ausgeben. Diesen Zwischenstand, dessen Informationen durch den Inhalt der CPU-Register beschrieben werden, speichert das System im Stack und räumt daraufhin den Prozessor für den nächsten Thread in der Warteschlange.

Der zweite Thread, dem anschließend die CPU zugeteilt wird, tritt nun seinerseits zum ersten Mal in die Schleife ein, erkennt den aktuell gültigen Feldinhalt der Variablen (er beträgt 40), erhöht diesen zunächst auf 41, gibt den Wert aus und setzt die Schleife so lange fort, bis seine Zeit abgelaufen ist. Dann verlässt der zweite Thread die CPU, das System liest die im Stack gesicherten Daten des ersten Threads in die CPU ein und setzt die Arbeit mit genau der Anweisung fort, bei der er unterbrochen wurde: mit der Ausgabe der Zahl 40 an der Konsole.


Rheinwerk Computing - Zum Seitenanfang

15.2.8 Der »Monitor« zur SynchronisationZur nächsten ÜberschriftZur vorigen Überschrift

Beide Threads des Beispiels arbeiten ohne Synchronisation und hinterlassen ihrem Nachfolger die Ressource in einem ungültigen Zustand. Das wollen wir natürlich vermeiden – die Feldinhalte sollen so ausgegeben werden, dass sie dem tatsächlich aktuellen Stand des Feldes entsprechen.

Wenn wir die Arbeitsweise der Methode Worker analysieren, stellen wir zu fest, dass ein ganz bestimmter Codeteil als kritisch angesehen werden kann. Es sind die beiden Anweisungen:

value++;
Console.WriteLine(intVar);

Die Ausgabe wird nur dann unseren Erwartungen entsprechen, wenn ein laufender Thread seine Ausführung nicht zwischen diesen beiden Anweisungen unterbrechen muss, denn zur Erhöhung des Feldwertes gehört auch die Anzeige an der Konsole. Dieser Zusammenhang muss für jeden der beiden Threads ersichtlich sein.

An dieser Stelle kommt eine neue Klasse ins Spiel, die die Aufgabe der Synchronisation übernimmt: Monitor. Mit dieser Klasse lässt sich verhindern, dass mehrere Threads gleichzeitig einen bestimmten Codeteil im Programm durchlaufen. Mit anderen Worten bedeutet das, dass zu einem Zeitpunkt immer nur ein Thread dieses Codesegment durchlaufen kann. Andere Threads, die ebenfalls dieses Codesegment ausführen wollen, müssen warten, bis der laufende Thread das Codesegment verlassen hat.

Mit den Methoden Enter und Exit der Klasse Monitor können kritische Codeabschnitte definiert werden, die zu einem gegebenen Zeitpunkt nur von einem Thread betreten werden dürfen. Mit Enter wird das Codesegment so lange blockiert, bis die Sperrung mit Exit wieder aufgehoben wird. Damit sind ungültige Zustände, die ein Thread hinterlassen könnte, wenn ihm die Zeitscheibe entzogen wird, nicht mehr möglich. Monitor protokolliert, ob der Vorgängerthread den kritischen Abschnitt mit Exit ordnungsgemäß verlassen hat oder nicht.

Sowohl Enter als auch Exit sind statische Methoden der Klasse Monitor. Als Argument wird den beiden Methoden die Referenz auf das zu synchronisierende Objekt übergeben, das auch this sein darf.

Wir ändern jetzt das Beispiel oben und schaffen die Voraussetzung dafür, dass die Zugriffe auf die kritischen Anweisungen synchronisiert erfolgen. Zur Bestätigung lassen wir uns diesmal zusätzlich noch den Hashcode des jeweiligen Threads ausgeben, der die angezeigte Zahl erzeugt hat.

// Beispiel: ..\Kapitel 15\SynchronisierteThreads
[...]
class Demo {
private int value;
public void Worker() {
while(true) {
// Sperre setzen
Monitor.Enter(this);
value++;
if (value > 100) break;
Console.WriteLine("Zahl = {0,5} Thread = {1,3}", value,
Thread.CurrentThread.GetHashCode().ToString());
Thread.Sleep(5);
// Sperre aufheben
Monitor.Exit(this);
}
}
}

Listing 15.9 Synchronisation mit der Klasse »Monitor«

Nun erhalten wir wunschgemäß die Ausgabe der chronologisch geordneten Zahlen von 1 bis 100.

Neben der Enter-Methode gibt es in der Monitor-Klasse noch die Methode TryEnter. Diese überprüft zuerst, ob der geschützte Codeabschnitt frei ist, sperrt ihn dann und führt den Code aus. Ist das Codesegment jedoch gesperrt, liefert TryEnter den Rückgabewert false. Darauf kann der Entwickler beispielsweise in einer if-Anweisung reagieren.

Das »lock«-Statement

Neben Enter und Exit der Klasse Monitor gibt es noch eine andere, sprachspezifische Möglichkeit, den Zugriff zu synchronisieren. Unter C# ist das die lock-Anweisung. Die Syntax dazu lautet:

lock (Ausdruck)
{
// zu synchronisierende Anweisungen
}

Durch den Anweisungsblock hinter lock werden die Anweisungen eingeschlossen, die es zu synchronisieren gilt. Dieses Statement ist sehr einfach zu handhaben, aber es besitzt nicht die Möglichkeiten, mit denen die Klasse Monitor ausgestattet ist.

Die Methoden »Wait« und »Pulse«

Die Klasse Monitor ist nicht instanziierbar, da jedem Objekt nur ein Monitor zugeordnet werden kann. Mehrere Objekte können den Anspruch auf die Nutzung des Monitors eines anderen Objekts erheben, aber nur einem Objekt aus der Warteschlange wird er zugestanden.

Stellen Sie sich den Monitor wie ein Fernglas vor, das Sie mit in den Urlaub genommen haben, um damit die Landschaft aus der Nähe zu betrachten. Solange Sie das Fernglas benutzen, hat keine andere Person die Möglichkeit, die schönen Dinge der Natur aus der Nähe zu betrachten. Eine andere Person, die auch einen Blick durch das Fernglas werfen möchte, wird sich in die Warteschlange einreihen müssen. Erst wenn Sie das Fernglas zur Seite gelegt haben, kann es von einer Person aus der Warteschlange aufgenommen werden. Alle anderen Personen müssen sich weiter gedulden.

Nehmen wir jetzt an, Sie wären mit einem Ihrer Freunde im Urlaub. Während Sie durch das Fernglas schauen, erhebt auch Ihr Freund darauf Anspruch. Sie legen das Fernglas freiwillig zur Seite, informieren Ihren Freund darüber, dass er es nun benutzen darf, und treten freiwillig in die Warteschlange.

Die letzten beiden Aktionen lassen sich auch auf den Monitor projizieren. Sobald Sie das Fernglas mit der Absicht zur Seite legen, es zu einem späteren Zeitpunkt noch einmal zu benutzen, versetzen Sie sich in den Wartezustand und begeben sich in die Warteschlange. Die Monitor-Klasse beschreibt diese Operation mit der statischen Methode Wait. Das Informieren des nächsten Interessenten in der Warteschlange entspricht der ebenfalls statischen Methode Pulse. Beide Methoden können nur innerhalb eines Synchronisationsblocks aufgerufen werden.

Mit Wait wird der aktuelle Thread blockiert und gleichzeitig die Sperrung des Objekts aufgehoben. Damit kann ein anderer Thread das freigegebene Objekt nutzen. Schauen wir uns eine Definition der überladenen Wait-Methode an:

public static bool Wait(object obj);

Der Parameter nimmt die Referenz auf das Objekt entgegen, dessen Sperrung aufgehoben werden soll. Ein wenig sonderbar verhält sich der Rückgabewert. Er ist true, wenn kein anderer Thread das Objekt sperrt und der aktuelle Thread selbst die Verantwortung der Sperrung übernimmt. Ansonsten kommt kein boolescher Wert zurück, was eine Einreihung in die Warteschlange zur Folge hat. Damit bietet sich Wait auch dazu an, als Bedingung für den Eintritt in eine Schleife behilflich zu sein:

while(Monitor.Wait(obj)) {
// Thread tritt in den synchronisierten Block ein
}

Es besteht ein großer Unterschied zwischen einem Thread, der mit Enter auf den Eintritt in eine synchronisierte Methode wartet, und einem Thread, der sich mit Wait in den Wartezustand versetzt hat. Ein Thread, der eine synchronisierte Methode mit Enter betreten möchte, befindet sich im Zustand bereit. Er reiht sich in die Threads ein, die auf Anweisung des Schedulers hin ein Segment der Zeitscheibe erhalten. Ein Thread, der mit Wait die Sperrung eines Objekts aufgehoben hat, befindet sich in einer Warteliste – allerdings nicht in der Warteliste, aus der der Scheduler einem bereiten Thread die CPU zuteilt, sondern in einer Warteliste aller der Threads, die durch den Zustand wartend gekennzeichnet sind.

Um einen Thread aus seinem Wartezustand zu holen, muss ein anderer Thread die Methode Pulse oder PulseAll auf dem gesperrten Objekt aufrufen. Das Problem ist, dass Pulse keinen bestimmten wartenden Thread aus der Liste holt, sondern – falls sich mehrere Threads darin befinden – einen mehr oder weniger willkürlich gewählten, während mit PulseAll alle Threads den Zustand wartend aufgeben und in bereit übergehen. Damit stehen sie wieder in der Warteschlange der Zeitscheibe – der Scheduler kann ihnen wieder Prozessorzeit zuteilen.

Ein Thread, der mit Wait die Sperrung des kritischen Codebereichs aufgehoben hat, wartet auf einen Anstoß von außen, um wieder aktiv werden zu können. Er selbst hat keine Möglichkeit, diesen Zustand zu beenden. Wenn kein anderer Thread Pulse oder PulseAll aufruft, wird ein wartender Thread daher niemals mehr laufen können.

Im Extremfall kann der Wartezustand der Threads einer Anwendung zu einem Phänomen führen, das unter der Bezeichnung Deadlock bekannt ist. Dabei befinden sich ausnahmslos alle Threads im blockierten Wartezustand. Die Anwendung hängt sich in diesem Moment auf.

Wir wollen nun die vorgestellten Methoden in einem Beispiel testen. Dazu werden wir ein Programm entwickeln, das in Lage ist, Zahlen zu erzeugen. Das ist eigentlich nichts Weltbewegendes, und wir haben auch schon in den anderen Beispielen Zahlen erzeugt. Das Besondere ist jedoch, dass jede Zahl genau einmal von einem Verbraucher ausgewertet werden soll. Der Verbrauch soll durch eine Konsolenausgabe simuliert werden. Erzeuger und Konsument sollen in einem eigenen Thread laufen.

// Beispiel: ..\ Kapitel 15\Zahlenkonsument
class Program {
public static bool finished = false;
public static bool thread1Waiting = false;
public static bool thread2Waiting = false;
static void Main(string[] args) {
MyNumber zahl = new MyNumber();
ProduceNumber prod = new ProduceNumber(zahl);
ConsumeNumber cons = new ConsumeNumber(zahl);
Thread thread1, thread2;
// Threads instanziieren
thread1 = new Thread(new ThreadStart(prod.MakeNumber));
thread2 = new Thread(new ThreadStart(cons.GetNumber));
// Threads starten
thread1.Start();
thread2.Start();
Console.ReadLine();
}
}
// ------ erzeugt eine Zahl ------------
class ProduceNumber {
private MyNumber obj;
public ProduceNumber(MyNumber obj) {
this.obj = obj;
}
public void MakeNumber() {
Random rnd = new Random();
Monitor.Enter(obj);
for (int i = 0; i <= 10; i++) {
Program.thread1Waiting = true;
// falls der Konsumerthread noch nicht im Wartezustand ist,
// selbst in den Wartezustand gehen
if(Program.thread2Waiting == false)
Monitor.Wait(obj);
obj.Number = rnd.Next(0, 1000);
Console.WriteLine("Nummer {0} erzeugt", obj.Number);
// dem nächsten in der Warteschlange stehenden Objekt
// den Monitor übergeben
Monitor.Pulse(obj);
Program.thread2Waiting = false;
}
Program.finished = true;
Monitor.Exit(obj);
}
}
// --------- verbraucht eine Zahl -------------
class ConsumeNumber {
private MyNumber obj;
public ConsumeNumber(MyNumber obj) {
this.obj = obj;
}
public void GetNumber() {
Monitor.Enter(obj);
// wenn sich der Erzeugerthread im Wartezustand
// befindet, ihn 'bereit' schalten
if(Program.thread1Waiting)
Monitor.Pulse(obj);
Program.thread2Waiting = true;
while(Monitor.Wait(obj)) {
Console.WriteLine("Nummer {0} verbraucht",obj.Number);
Monitor.Pulse(obj);
if(Program.finished) Thread.CurrentThread.Abort();
}
Monitor.Exit(obj);
}
}
// ------------ repräsentiert eine Zahl -------------------
class MyNumber {
private int intValue;
public int Number {
get {return intValue;}
set {intValue = value;}
}
}

Listing 15.10 Synchronisation zweier Threads

Der Kern der Anwendung wird durch die beiden Klassen ProduceNumber und ConsumeNumber beschrieben. ProduceNumber erzeugt mit der Methode MakeNumber auf Basis des Zufallszahlengenerators Zahlen zwischen 0 und 999 und schreibt diese in das Feld eines Objekts vom Typ der Klasse MyNumber, das in Main erzeugt wird und dessen Referenz den Konstruktoren der Klassen ConsumeNumber und ProduceNumber übergeben wird. Damit ist sichergestellt, dass sowohl der Erzeuger als auch der Verbraucher mit demselben MyNumber-Objekt operieren.

Betrachten wir nun die prinzipielle Arbeitsweise des Verbrauchers und des Konsumenten unter der Prämisse, dass zuerst der Erzeugerthread und danach der Verbraucherthread gestartet wird. Der gesamte Code in der Routine MakeNumber ist synchronisiert. Insgesamt werden elf Zahlen in einer Schleife erzeugt. Direkt nach dem Schleifeneintritt wird die Wait-Methode des Monitors aufgerufen und die Sperre des Objekts vom Typ MyNumber aufgehoben. Jetzt kann ein anderer Thread auf das freigegebene MyNumber-Objekt zugreifen und die Zahl »verbrauchen«, die im Schleifendurchlauf zuvor erzeugt worden ist. Der Erzeugerthread verharrt so lange in Wartestellung, bis der Verbraucherthread Pulse aufruft und den Erzeugerthread wieder in den Zustand bereit versetzt. Ist dessen Wartezustand aufgehoben, wird die nächste Zahl erzeugt. Bevor der nächste Schleifendurchlauf ausgeführt wird, wird der Verbraucher mit Pulse in den Zustand bereit erhoben.

In der Methode GetNumber des Verbrauchers ist der Programmcode ebenfalls synchronisiert. Direkt nach dem Eintritt in den Synchronisierungsabschnitt wird Pulse aufgerufen, um den wartenden Erzeugerthread nach dem Start der Anwendung in den Zustand bereit zu versetzen. Anschließend ruft der Konsument Wait auf. Damit wird der Monitor des Objekts an den Erzeuger weitergegeben, der eine Zahl erzeugt. Gibt der Erzeuger die Sperre an den Konsumenten zurück, wird die neue Zahl zuerst an der Konsole angezeigt und anschließend Pulse aufgerufen. Jetzt ist der Erzeugerthread wieder im Zustand bereit, während der Konsument seinerseits anschließend den Monitor freigibt und sich in den Wartezustand versetzt.

Damit der Verbraucherthread überhaupt erfährt, wann der Erzeuger die letzte Zahl bereitgestellt hat, ist die boolesche Variable finished in Program deklariert, die vom Erzeuger nach dem letzten Schleifendurchlauf auf true gesetzt wird.

So viel zur prinzipiellen Arbeitsweise. Es gibt aber noch ein Problem, dem wir bisher noch keine Beachtung geschenkt haben: Wir können nämlich nicht garantieren, dass der Erzeugerthread als Erstes gestartet wird. Erhält nach dem Starten der Anwendung der Konsument vor dem Erzeuger die CPU, würde es zu einem klassischen Deadlock kommen, wenn sich beide Threads gleichzeitig im Zustand wartend befinden. Wir müssen also eine genaue Steuerung des Programmablaufs in der Weise erzwingen, dass sich der Verbraucherthread im Wartezustand befindet, wenn die erste Zahl erzeugt wird. Diese Steuerung wird über die beiden booleschen Variablen thread1Waiting und thread2Waiting erreicht, deren Auswertung garantiert, dass sich – unabhängig von der Startreihenfolge – zu einem gegebenen Zeitpunkt immer nur ein Thread im Wartezustand befindet.

Abbildung

Abbildung 15.5 Die Ausgabe des Beispielprogramms »Zahlenkonsument«


Rheinwerk Computing - Zum Seitenanfang

15.2.9 Das Attribut »MethodImpl«Zur nächsten ÜberschriftZur vorigen Überschrift

Es gibt noch eine weitere Alternative, um die Synchronisierung zwischen mehreren Threads zu erzielen: mit dem Attribut MethodImpl. Die zugrunde liegende Klasse ist im Namespace System.Runtime.CompilerServices zu finden. Das Attribut kann nur auf Konstruktoren und Methoden angewendet werden. Es ersetzt die Klasse Monitor mit dem Unterschied, dass nicht nur ein bestimmtes Codesegment eingehüllt wird, sondern in einem Zug gleich die gesamte Methode. Somit kann auch immer nur ein Thread zu einem bestimmten Zeitpunkt diese Methode ausführen. Dazu ein Beispiel:

[MethodImpl(MethodImplOptions.Synchronized)]
public void Calculate()
{
// Anweisungen
}

Listing 15.11 Synchronisation mit dem Attribut »MethodImplAttribute«

Dem Attribut MethodImpl können verschiedene Parameter übergeben werden. Zur Synchronisation verwenden Sie MethodImplOptions.Synchronized.


Rheinwerk Computing - Zum Seitenanfang

15.2.10 Das Synchronisationsobjekt »Mutex«Zur nächsten ÜberschriftZur vorigen Überschrift

Die Klasse Monitor eignet sich nur zur Synchronisation von Threads, die innerhalb eines Prozessraums laufen. Manchmal müssen Abläufe aber auch über Prozessgrenzen hinweg synchronisiert werden. In diesen Fällen müssen Sie die Klasse Mutex einsetzen. Ich möchte Ihnen hierzu ein Beispiel zeigen, bei dem ein Mutex-Objekt dazu benutzt wird, zu verhindern, dass eine Anwendung mehrfach gestartet werden kann.

// Beispiel: ..\Kapitel 15\MutexSample
class Program {
private static Mutex mutex;
static void Main(string[] args) {
if (IsApplicationStarted()) {
Console.WriteLine("Die Anwendung wurde bereits gestartet");
Console.WriteLine("Ein zweiter Start ist nicht möglich.");
}
else {
Console.WriteLine("Die Anwendung wird gestartet.");
Console.WriteLine("Die Anwendung läuft.");
}
Console.ReadLine();
}
public static bool IsApplicationStarted() {
string mutexName = Application.ProductName;
mutex = new Mutex(false, mutexName);
if (mutex.WaitOne(0, true))
return false;
else
return true;
}
}

Listing 15.12 Einsatz eines Mutex

Ein Mutex ist ein einfaches Systemobjekt und durch einen eindeutigen Namen gekennzeichnet. Ein Mutex-Objekt gestattet nur jeweils einem Thread exklusiven Zugriff auf die gemeinsam genutzte Ressource. In unserem Beispiel wird das die Anwendung selbst sein. Wenn ein Thread ein Mutex-Objekt erhält, wird ein zweiter Thread, der dieses Objekt abruft, so lange angehalten, bis der erste Thread das Mutex-Objekt freigibt.

Die Klasse Mutex stellt mehrere Konstruktoren zur Verfügung. Für unsere Belange ist der geeignet, dem wir den Namen des Mutex mitteilen können. Die Schwierigkeit besteht bei der Namensvergabe darin, dass der Name systemeindeutig sein muss, um den Mutex identifizieren zu können. Es bietet sich hier der Anwendungsname an, obwohl dieser auch keine Garantie für Eindeutigkeit gibt. Möglicherweise müssen Sie hier noch Zusatzinformationen hinzufügen, um die Eindeutigkeit zumindest mit hoher Wahrscheinlichkeit zu gewährleisten. Der entsprechende Konstruktor erwartet darüber hinaus auch noch einen booleschen Wert, der angibt, ob dem aufrufenden Thread der anfängliche Besitz des Mutex zugewiesen werden soll. Er ist true, um dem aufrufenden Thread den anfänglichen Besitz des benannten Mutex zuzuweisen.

Über die Methode WaitOne kann eine Ressource das Mutex-Objekt anfordern. Ist der Rückgabewert true, ist das Objekt nicht im Besitz eines anderen Threads, sonst wäre der Rückgabewert false.

WaitOne werden zwei Argumente übergeben: Das erste beschreibt ein Zeitintervall, das angibt, wie lange auf ein Signal gewartet werden soll. Über das zweite Argument können Sie bei Anwendungen, die vor dem Warten auf den Mutex den Zugriff auf Objekte oder Klassen über lock sperren, festlegen, dass die Sperrung vor dem Warten aufgehoben und nach dem Warten wieder gesetzt wird. Für uns hat dieser Parameter keine Bedeutung, wir setzen ihn auf true.

Im Beispielprogramm dient die Methode IsApplicationStarted dazu, zu prüfen, ob das Mutex-Objekt sich bereits im Besitz eines anderen Threads befindet. Je nachdem, wie das Ergebnis der Prüfung ausfällt, wird eine entsprechende Konsolenausgabe erscheinen.


Rheinwerk Computing - Zum Seitenanfang

15.2.11 Grundlagen asynchroner MethodenaufrufeZur nächsten ÜberschriftZur vorigen Überschrift

Wird aus einer Methode A heraus die Methode B aufgerufen, wird A erst dann mit den Operationen fortfahren, wenn B vollständig abgearbeitet ist. Die Ausführung der beiden Methoden erfolgt hintereinander, was als synchron bezeichnet wird. Synchrone Operationen haben einen gravierenden Nachteil, denn solange die Methode B ausgeführt wird, ist die Methode A blockiert. Um diese Problematik zu vermeiden, sollten beide Methoden asynchron, d. h. parallel nebeneinander operieren.

Asynchrone Bearbeitung setzt mindestens zwei Threads voraus. Sie haben auf den vergangenen Seiten die wichtigsten Techniken kennengelernt, um mit Threads zu arbeiten. Sie wissen nun, wie Sie Threads erzeugen und diese möglicherweise synchronisieren können, damit Elemente nicht in einem ungültigen Zustand hinterlassen werden. Ihnen dürfte dabei nicht entgangen sein, dass die Technik sehr komplex ist und einer genauen Planung bedarf, um keine unbeabsichtigten und bösen Überraschungen zu erleben.

Auch in der .NET-Klassenbibliothek finden sich sehr viele Klassen, die Dienste anbieten, deren Ausführung möglicherweise länger dauern kann. Die Dateioperationen zum Lesen und Schreiben zählen dazu. Betrachten wir dazu exemplarisch die Klasse FileStream, die das Schreiben in eine Datei bzw. das Lesen aus einer Datei ermöglicht. (Anmerkung: Wir werden uns den Klassen zur Ein- und Ausgabe in Kapitel 10 zuwenden.) Neben den obligatorischen Methoden Read und Write, die beide synchron ausgeführt werden, werden von dieser Klasse aufgrund der weisen Voraussicht des .NET-Entwicklerteams auch die asynchron operierenden Methoden BeginRead und BeginWrite veröffentlicht. Sehen wir uns kurz die Definition der erstgenannten Methode an, die aus einem Datenstrom in ein byte-Array einliest:

public override IAsyncResult BeginRead(byte[] array,
int offset,
int numBytes,
AsyncCallback userCallback,
object stateObject);

Der Rückgabewert des Methodenaufrufs ist ein Objekt, das die Schnittstelle IAsyncResult implementiert. Der Parameter vom Typ AsyncCallback ist ein Delegate, der eine Methode im Client beschreibt, die nach der Beendigung der Leseoperation aufgerufen wird.

Sowohl BeginRead als auch BeginWrite haben jeweils eine Partnermethode: EndRead und EndWrite:

public override int EndRead(IAsyncResult asyncResult);
public override void EndWrite(IAsyncResult asyncResult);

Auch diese Methoden erwarten eine Referenz vom Typ IAsyncResult.

Zwei Dinge fallen sofort auf:

  • Die Methoden arbeiten gemäß Dokumentation asynchron, ohne dass im Aufrufer explizit ein separater Thread gestartet werden muss. Diese Leistung wird von den Methoden intern erbracht.
  • Es treten zwei Typen auf, denen Sie hier zum ersten Mal begegnen und deren Bedeutung noch unbekannt ist: IAsyncResult und AsyncCallback.

Wir wollen uns nun mit der Codierung einer asynchronen Ausführung beschäftigen. Danach wird auch die im ersten Moment sehr kompliziert erscheinende Parameterliste asynchron arbeitender Methoden (wie BeginRead) in einem anderen Licht erscheinen.


Rheinwerk Computing - Zum Seitenanfang

15.2.12 Asynchroner MethodenaufrufZur nächsten ÜberschriftZur vorigen Überschrift

Der C#-Compiler stellt mit BeginInvoke und EndInvoke jedem Delegate zwei Methoden zur Verfügung, die im Rahmen einer asynchronen Operation von entscheidender Bedeutung sind.

Beachten Sie bitte, dass es sich bei BeginInvoke und EndInvoke um sprachspezifische Methoden handelt, die nicht in der Klasse Delegate definiert sind, aber dennoch von jedem .NET-Compiler veröffentlicht werden sollten – möglicherweise auch unter einem anderen Namen.

Die Methode BeginInvoke ist sehr mächtig, denn wenn Sie sie auf der Referenz eines Delegates aufrufen, wird ein Hintergrundthread erzeugt, in dem die vom Delegate beschriebene Methode ausgeführt wird. Der aufrufende Thread macht mit seiner eigenen Arbeit weiter, anstatt auf die Beendigung der aufgerufenen Methode zu warten.

Dazu ein kleines Beispiel. Nehmen wir an, dass die Methode DoSomething, die eine längere Zeit zur Ausführung benötigt, aufgerufen werden soll. DoSomething sei wie folgt definiert:

public void DoSomething() {
for(int i = 0; i <= 30; i++) {
Console.Write(".X.");
Thread.Sleep(10);
}
}

Listing 15.13 Eine Methode, die längere Zeit für die Ausführung benötigt

Ein Client, der diese Methode asynchron ausführen möchte, kann einen Delegate deklarieren und diesem die Adresse der Methode DoSomething übergeben:

public delegate void MyDelegate();
[...]
MyDelegate del = new MyDelegate(obj.DoSomething);
del.BeginInvoke(...);

Das reicht bereits aus, um DoSomething in einem separaten Thread abzuarbeiten.

Dem Aufruf von BeginInvoke müssen Argumente übergeben werden, die unsere Anweisung noch nicht enthält. Sehen wir uns deshalb nun die Definition von BeginInvoke an.

public IAsyncResult BeginInvoke([Parameterliste,] 
AsyncCallback, Object);

Aufgerufen wird BeginInvoke auf die Instanz eines Delegates, der auf eine bestimmte Methode zeigt. Weist die aufzurufende Methode eine Parameterliste auf, müssen die erforderlichen Argumente von BeginInvoke an die Methode weitergeleitet werden. Dazu dient die optionale Parameterliste.

Theoretisch wäre das bereits vollkommen ausreichend, um die aufgerufene Methode asynchron auszuführen. In der Regel benötigt der aufrufende Code aber Kenntnis von der Beendigung der asynchronen Ausführung, beispielsweise wenn er die Rückgabewerte verarbeitet. Folglich muss es eine Möglichkeit geben, die es der asynchron aufgerufenen Methode ermöglicht, den Aufrufer davon zu unterrichten, dass sie ihre Operationen beendet hat. Dabei kann es sich nur um den Aufruf einer Methode im Initiator der asynchronen Operation handeln.

Konsequenterweise muss der asynchron aufgerufenen Methode die Adresse der Rückrufmethode im Aufrufer bekannt sein. Das klingt wieder verdächtig nach einem Delegate – und tatsächlich ist dem so, denn dem Aufruf von BeginInvoke werden nicht nur die Argumente übergeben, die die asynchron aufgerufene Methode benötigt, sondern darüber hinaus auch ein Objekt vom Typ AsyncCallback, bei dem es sich um den erforderlichen Delegate handelt.

Die Definition des Delegates AsyncCallback lautet:

public delegate void AsyncCallback(IAsyncResult ar);

Die Methode, die aus der asynchron ausgeführten Methode zurückgerufen wird, muss den Rückgabetyp void aufweisen und einen Parameter vom Typ IAsyncResult definieren. BeginInvoke verfügt noch über einen weiteren Parameter vom Typ object. Hier kann beim Start der asynchronen Operation ein beliebiges Objekt übergeben werden, das Informationen beliebiger Art enthält.

Das hört sich komplizierter an, als es tatsächlich ist. Daher wollen wir den Ablauf schrittweise an einem kleinen Beispiel verfolgen. Gegeben seien dazu die beiden Klassen Program und Demo wie folgt:

class Program {
static void Main(string[] args) {
[...]
}
}
class Demo {
public void DoSomething() {
[...]
}
}

Aus Main heraus soll die Methode DoSomething in der Klasse Demo asynchron aufgerufen werden. Diese Forderung bewirkt, dass wir BeginInvoke auf einem Delegate aufrufen müssen, der die asynchron auszuführende Methode im Objekt vom Typ Demo beschreibt. Dazu wird zunächst ein Delegate mit

public delegate void MyDelegate();

deklariert. Anschließend verschaffen wir uns ein Objekt vom Typ des Delegates, dem als Argument die asynchron auszuführende Methode übergeben wird.

private MyDelegate del;
[...]
del = new MyDelegate(obj.DoSomething);

Mit

del.BeginInvoke(...);

wird die asynchrone Ausführung von DoSomething in einem Hintergrundthread gestartet. Allerdings ist die Anweisung noch unvollständig – symbolisiert durch die Punkte. Wir sollten in Program nämlich noch eine Methode bereitstellen, mit der der Hintergrundthread das Objekt vom Typ Program über das Ende seiner Operation benachrichtigt. Die Definition der Rückrufmethode muss der Vorgabe des Delegates AsyncCallback entsprechen, demnach also einen Parameter vom Typ IAsyncResult enthalten. Wir nennen diese Methode CallbackMethod.

class Program {
[...]
static void Main(string[] args) {...}
public static void CallbackMethod(IAsyncResult ar) {
[...]
}
}

Das Objekt vom Typ IAsyncResult entspricht dem Rückgabewert von BeginInvoke. Es veröffentlicht insgesamt sechs Eigenschaften. Dazu gehört unter anderem auch IsCompleted. Über IsCompleted kann der Aufrufer jederzeit feststellen, ob die asynchrone Ausführung bereits beendet ist. Eine zweite, sehr interessante Eigenschaft ist AsyncState, die genau das Objekt abruft, das als letzter Parameter dem Aufruf von BeginInvoke übergeben worden ist. Sie werden später in einem anderen Beispiel die sinnvolle Auswertung dieses Objekts sehen.

Wir wollen nun unser Beispiel komplettieren und Code einsetzen, der tatsächlich einige Zeit in Anspruch nimmt, damit wir den Effekt des asynchronen Aufrufs beobachten können.

// Beispiel: ..\Kapitel 15\AsynchronerAufruf_1
public delegate void MyDelegate();
class Program {
private static MyDelegate del;
static void Main(string[] args) {
Demo obj = new Demo();
del = new MyDelegate(obj.DoSomething);
AsyncCallback callback = new AsyncCallback(CallbackMethod);
// die Methode AsyncTest in Demo asynchron aufrufen
del.BeginInvoke(callback, null);
// zeitaufwendige Ausführung
for(int i = 0; i <= 100; i++) {
Console.Write(".");
Thread.Sleep(10);
}
Console.ReadLine();
}
// die zurückgerufene Methode
public static void CallbackMethod(IAsyncResult ar) {
Console.Write("Ich habe fertig.");
}
}
class Demo {
// asynchron aufzurufende Methode
public void DoSomething() {
// zeitintensive Ausführung
for(int i = 0; i <= 30; i++) {
Console.Write("X");
Thread.Sleep(10);
}
}
}

Listing 15.14 Asynchroner Methodenaufruf

In Abbildung 15.6 ist das Ergebnis des Aufrufs zu sehen. Es ist eindeutig zu erkennen, dass die Punkte und »X«-Zeichen mehr oder weniger abwechselnd ausgegeben werden, denn beide Methoden arbeiten parallel. Beendet wird die asynchrone Operation durch den Rückruf von CallbackMethod, was durch die Ausgabe des bekannten Satzes »Ich habe fertig« bestätigt wird.

Abbildung

Abbildung 15.6 Ausgabe eines asynchronen Aufrufs


Rheinwerk Computing - Zum Seitenanfang

15.2.13 Asynchroner Aufruf mit RückgabewertenZur nächsten ÜberschriftZur vorigen Überschrift

Möglicherweise liefert die asynchrone Methode als Resultat ihrer Operation einen Rückgabewert. Vielleicht werden auch über die Parameterliste Ergebnisse bereitgestellt. Wird aus dem Hintergrundthread heraus die Rückrufmethode des Initiators der asynchronen Operation aufgerufen, stehen die Ergebnisse jedoch nicht automatisch zur Verfügung. Sie müssen ausdrücklich abgerufen werden. Dazu dient die Methode EndInvoke des Delegates.

public Datentyp EndInvoke([Parameterliste,] IAsyncResult);

Wie bei BeginInvoke müssen Sie auch an EndInvoke eine vorgeschriebene Parameterliste übergeben, die nicht identisch mit der Parameterliste von BeginInvoke ist: Sie darf nur die Referenzparameter der asynchronen Methode enthalten, damit EndInvoke die Resultate dort hineinschreiben kann. Die Angabe der Werteparameter ist nicht erlaubt. Der einzige grundsätzlich immer zwingend erforderliche Parameter ist vom Typ IAsyncResult. Hier wird das Objekt übergeben, das beim Aufruf von BeginInvoke dem letzten Parameter übergeben worden ist.

Wir wollen nun das Beispiel AsynchronerAufruf_1 ändern, um zu sehen, wie eine asynchrone Methode behandelt wird, die sowohl Werte- als auch Referenzparameter erwartet und darüber hinaus auch noch einen Rückgabewert hat. Dazu implementieren wir die Methode DoSomething wie folgt:

public string DoSomething(int x, ref long y) {
// zeitaufwendige Ausführung
for(int i = 0; i <= 30; i++) {
Console.Write("X");
Thread.Sleep(10);
}
y = 12345;
return "Ich habe fertig.";
}

Listing 15.15 Methode mit Rückgabewert

Die Parameterliste enthält jetzt den Referenzparameter y und den Werteparameter x, außerdem liefert die Methode eine Zeichenfolge zurück.

Die Änderung der Signatur hat natürlich auch im auslösenden Thread Konsequenzen. Der Delegate, der den Aufruf der Methode kapselt, muss an die veränderten Bedingungen angepasst werden:

public delegate string MyDelegate(int x, ref long y);

Gleiches gilt auch für den Start der asynchronen Bearbeitung, denn nun reicht es nicht mehr aus, mit BeginInvoke einfach nur einen Delegate auf die Rückrufmethode zu übergeben sowie die Referenz auf ein Objekt, in das der asynchrone Aufruf Informationen schreiben könnte. Wir müssen stattdessen auch die Parameter der asynchronen Methode in der richtigen Reihenfolge bedienen:

del.BeginInvoke(intVar, ref lngVar, callback, null);

DoSomething nimmt nun eine Kopie des int-Wertes und die Adresse des long-Wertes entgegen, kann mit diesen die erforderlichen Operationen ausführen und zum Abschluss durch Aufruf der über callback bekannt gegebenen Adresse die Methode CallbackMethod informieren.

Der Implementierung der Rückrufmethode kommt nun eine entscheidende Bedeutung zu. Es gilt, sowohl den Rückgabewert als auch den in diesem Fall geänderten Inhalt der Variablen lngVar auszuwerten. Dem Aufruf von EndInvoke übergeben wir die Adresse von lngVar und holen uns den Rückgabewert an der Konsole ab:

public static void CallbackMethod(IAsyncResult ar) {
Console.Write(del.EndInvoke(ref lngVar, ar));
Console.Write("..Wert y = {0}", lngVar);
}

Listing 15.16 Implementierung der Rückrufmethode

Die Konsolenausgabe bestätigt, dass unser Unterfangen von Erfolg beschieden ist: Wir erhalten sowohl die Zeichenfolge als auch den veränderten Inhalt des Feldes intVar.

Zum Abschluss fassen wir das Beispielprogramm noch einmal zusammen.

// Beispiel: ..\ Kapitel 15\AsynchronerAufruf_2
public delegate string MyDelegate(int x, ref long y);
class Program {
private static MyDelegate del;
private static int intVar = 4711;
private static long lngVar;
static void Main(string[] args) {
Demo obj = new Demo();
del = new MyDelegate(obj.DoSomething);
AsyncCallback callback = new AsyncCallback(CallbackMethod);
// die Methode AsyncTest in Demo asynchron aufrufen
del.BeginInvoke(intVar, ref lngVar, callback, null);
// zeitaufwendige Ausführung
for(int i = 0; i <= 100; i++) {
Console.Write(".P.");
Thread.Sleep(10);
}
Console.ReadLine();
}
public static void CallbackMethod(IAsyncResult ar) {
Console.Write(del.EndInvoke(ref lngVar, ar));
Console.Write("..Wert y = {0}", lngVar);
}
}
class Demo {
public string DoSomething(int x, ref long y) {
// zeitaufwendige Ausführung
for(int i = 0; i <= 30; i++) {
Console.Write(".X.");
Thread.Sleep(10);
}
y = 12345;
return "Ich habe fertig.";
}
}

Listing 15.17 Asynchroner Methodenaufruf mit Rückgabewert

Abbildung

Abbildung 15.7 Ausgabe des Beispiels »AsynchronerAufruf_2«


Rheinwerk Computing - Zum Seitenanfang

15.2.14 Eine Klasse mit asynchronen MethodenaufrufenZur vorigen Überschrift

Am Anfang dieses Abschnitts wurde schon darauf hingewiesen, dass einige Klassen der .NET-Klassenbibliothek Methoden mit asynchroner Verarbeitung anbieten. Die Klasse FileStream im Namespace System.IO ist ein Beispiel dafür. Es werden allerdings nicht die Methoden BeginInvoke und EndInvoke aufgerufen, sondern zwei ähnlich lautende: BeginRead und EndRead bzw. BeginWrite und EndWrite.

Wir wollen uns nun ansehen, wie eine Klasse aufgebaut ist, die ähnlich wie FileStream implementiert ist. Dabei lernen wir einerseits, wie wir die asynchronen Methoden der Klassen des .NET Frameworks behandeln müssen, andererseits aber auch, diese Technik in eigenen Klassen zu nutzen.

Am Anfang steht die Idee, eine Methode zu entwickeln, von der wir annehmen, dass sie in Abhängigkeit von den Umgebungsbedingungen und der Art der Operation eine längere Zeit zur Bearbeitung in Anspruch nehmen kann. Wir wollen diese Methode nachfolgend Calculate nennen, die Klasse dazu Mathematics.

class Mathematics {
public int Calculate(int x)
{
Console.Write("---Bearbeitung startet---");
for (int i = 0; i <= 20; i++) {
Console.Write(".X.");
Thread.Sleep(10);
}
Console.Write("---Bearbeitung beendet---");
return x * x;
}
}

Listing 15.18 Klasse mit einer zeitaufwendigen Methode

Die for-Schleife simuliert eine länger andauernde Operation. Diese Implementierung arbeitet synchron. Da wir uns bewusst sind, dass Calculate vielleicht auch eine Stunde zur vollständigen Ausführung brauchen könnte (wir sind mit unserer Annahme sehr großzügig), bieten wir zusätzlich eine asynchrone Variante an. Dazu benötigen wir zwei weitere Methoden, die einer allgemeinen Konvention folgend als BeginXxx und EndXxx bezeichnet werden – in unserer Klasse demnach BeginCalculate und EndCalculate. Die noch unvollständige Klassenstruktur sieht dann folgendermaßen aus:

class Mathematics {
// Methode Calculate wird synchron ausgeführt
public int Calculate(int x) {
...
}
// Start der asynchronen Ausführung
public ... BeginCalculate(...) {
...
}
// Beenden der asynchronen Ausführung
public ... EndCalculate(...) {
...
}
}

An dieser Stelle kommt es zu der wichtigsten Entscheidung überhaupt. Was wir beabsichtigen, ist die asynchrone Ausführung der Methode Calculate. Asynchronität heißt aber auch, dass ein weiterer Thread gestartet werden muss, sobald die Methode BeginCalculate aufgerufen wird. Wenn wir in dieser Methode ein Objekt vom Typ Thread erzeugen und seinem Konstruktor einen Delegate übergeben, bräuchten wir auch noch ein Objekt, das das Interface IAsyncResult implementiert, müssten zwangsläufig dessen Methoden implementieren usw.

Die Entwicklung auf diese Weise zu gestalten, ist sehr aufwendig. Es gibt eine viel einfachere Lösung, da die beiden Methoden BeginInvoke und EndInvoke genau das leisten, was wir brauchen. Also benutzen wir sie auch, um das Ziel effizient zu erreichen. Dazu wird die Logik, die in den Abschnitten 15.5.2 und 15.5.3 beschrieben wurde, innerhalb der Klasse Mathematics implementiert.

// Beispiel: ..\ Kapitel 15\AsynchronerAufruf_3
class Mathematics {
private delegate int CalculateHandler(int x);
CalculateHandler del;
// Methode Calculate wird synchron ausgeführt
public int Calculate(int x) {
Console.Write("---Bearbeitung startet---");
for (int i = 0; i <= 20; i++) {
Console.Write("X");
Thread.Sleep(10);
}
Console.Write("---Bearbeitung beendet---");
return x * x;
}
// Start der asynchronen Ausführung
public IAsyncResult BeginCalculate(int intVar,
AsyncCallback callback, object state)
{
del = new CalculateHandler(Calculate);
// Aufruf der Methode Calculate, die in einem eigenen
// Thread ausgeführt wird
return del.BeginInvoke(intVar, callback, state);
}
// Beenden der asynchronen Ausführung
public int EndCalculate(IAsyncResult ar) {
return del.EndInvoke(ar);
}
}

Listing 15.19 Klasse mit Methode für den synchronen und asynchronen Aufruf

Dem Aufruf der Methode BeginCalculate werden die Daten übergeben, die die Methode Calculate für ihre Operation benötigt. In unserem Beispiel handelt es sich nur um einen als Werteparameter deklarierten Integer. Der zweite Parameter erhält die Referenz auf einen Delegate, der die Rückrufmethode im Aufrufer beschreibt. Der dritte und letzte Parameter dient dazu, ein Objekt bereitzustellen, mit dem Daten zwischen dem aufrufenden und dem aufgerufenen Objekt ausgetauscht werden. Ein solches Objekt ist in unserem Beispielcode nicht vorgesehen.

Der Aufruf von BeginCalculate orientiert sich an dem von BeginInvoke – und das ist typisch für Klassen im .NET Framework, die asynchrone Methoden offenlegen. Unter ähnlicher Prämisse wird auch EndCalculate implementiert; der Rückgabewert des internen EndInvoke-Aufrufs wird zum Rückgabewert der Instanzmethode.

Zum Schluss müssen wir noch testen, ob die Klassenimplementierung auch unseren Anforderungen genügt.

class Program 
{
static void Main(string[] args)
{
Mathematics math = new Mathematics();
int value = 23;
AsyncCallback callback = new AsyncCallback(CallbackMethod);
// Aufruf der asynchronen Ausführung
math.BeginCalculate(value, callback, math);
for (int i = 0; i <= 100; i++) {
Console.Write(".{0}.", i);
Thread.Sleep(5);
}
Console.ReadLine();
}
// diese Methode wird vom Server aufgerufen
public static void CallbackMethod(IAsyncResult ar)
{
Mathematics math = (Mathematics)ar.AsyncState;
// das Ergebnis der asynchronen Operation abholen
int result = math.EndCalculate(ar);
Console.Write("---Resultat = {0} ", result);
Console.Write("---FERTIG---");
}
}

Listing 15.20 Testen des asynchronen Methodenaufrufs

Beachten Sie bitte die Variable math vom Typ Mathematics. Sie ist als lokale Variable in der Methode Main definiert und daher auch außerhalb der Methode nicht sichtbar. Um dennoch die EndCalculate-Methode dieses Objekts aufrufen zu können, wird die Referenz an den letzten Parameter von BeginCalculate übergeben. In BeginCalculate wird das Objekt an BeginInvoke weitergeleitet. Die Folge ist, dass wir in unserer Callback-Methode nur die Eigenschaft AsyncState auswerten müssen, denn hier kommt das Mathematics-Objekt genau dort an, wo wir dessen EndCalculate-Methode aufrufen müssen, um das Resultat der Operation abzufragen. Die Ausgabe an der Konsole wird so aussehen wie in Abbildung 15.8.

Abbildung

Abbildung 15.8 Ausgabe des Beispiels »AsynchronerAufruf_3«



Ihre Meinung

Wie hat Ihnen das Openbook gefallen? Wir freuen uns immer über Ihre Rückmeldung. Schreiben Sie uns gerne Ihr Feedback als E-Mail an kommunikation@rheinwerk-verlag.de.

<< zurück
  Zum Rheinwerk-Shop
Zum Rheinwerk-Shop: Visual C# 2012

Visual C# 2012
Jetzt Buch bestellen


 Ihre Meinung?
Wie hat Ihnen das Openbook gefallen?
Ihre Meinung

 Buchempfehlungen
Zum Rheinwerk-Shop: Professionell entwickeln mit Visual C# 2012






 Professionell
 entwickeln mit
 Visual C# 2012


Zum Rheinwerk-Shop: Windows Presentation Foundation






 Windows Presentation
 Foundation


Zum Rheinwerk-Shop: Schrödinger programmiert C++






 Schrödinger
 programmiert C++


Zum Rheinwerk-Shop: C++ Handbuch






 C++ Handbuch


Zum Rheinwerk-Shop: C/C++






 C/C++


 Lieferung
Versandkostenfrei bestellen in Deutschland, Österreich und der Schweiz
InfoInfo





Copyright © Rheinwerk Verlag GmbH 2013
Für Ihren privaten Gebrauch dürfen Sie die Online-Version natürlich ausdrucken. Ansonsten unterliegt das Openbook denselben Bestimmungen, wie die gebundene Ausgabe: Das Werk einschließlich aller seiner Teile ist urheberrechtlich geschützt.
Alle Rechte vorbehalten einschließlich der Vervielfältigung, Übersetzung, Mikroverfilmung sowie Einspeicherung und Verarbeitung in elektronischen Systemen.


Nutzungsbestimmungen | Datenschutz | Impressum

Rheinwerk Verlag GmbH, Rheinwerkallee 4, 53227 Bonn, Tel.: 0228.42150.0, Fax 0228.42150.77, service@rheinwerk-verlag.de

Cookie-Einstellungen ändern