In diesem Artikel wird beschrieben, wie Threads funktionieren und welche Fallstricke es dabei zu beachten gibt. Der erste Teil wird einen kurzen Überblick über die Technologie an sich geben. Im zweiten Teil geht es dann um die konkrete Anwendung. Der abschließende dritte Teil beschäftigt sich mit den möglichen Problemen, die bei der Programmierung von Threads auftreten können.
Grundlagen
Grundsätzlich wird zwischen Prozessen und Threads unterschieden. Als Prozess wird damit im Regelfall die gesamte Applikation bezeichnet. Innerhalb eines Prozesses können mehrere Unterprozesse gestartet werden, welche als Threads (Programmfäden) bezeichnet werden.
Jeder Prozess beinhaltet zumindest einen Thread, den Haupt-Thread. Dieser Thread wird ausgeführt, sobald die Ausführung des Prozesses beginnt. Ist der Haupt-Thread beendet, endet auch die Ausführung des Programmes. Wenn ein Thread gestartet wird, muss diesem explizit mitgeteilt werden, welche Methode sein Eintrittspunkt ist. Nach dem Starten des Threads wird dieser ausgeführt – und mit dem Beenden der Abarbeitung endet auch die Lebensdauer des Threads. Zu beachten ist dabei, dass jeder Thread seine eigenen lokalen Variablen einer Methode hat. Auch dann, wenn mehrere Threads gleichzeitig dieselbe Methode ausführen. Es werden nur Mitgliedsvariablen zwischen den Threads geteilt. Die Beendigung des Programmes geschieht erst dann, wenn alle Threads mit der Abarbeitung ihrer Methoden fertig sind. Eine Sonderstellung nimmt dabei der erste Thread eines Prozesses ein. Dieser wird nicht vom Entwickler direkt gestartet, sondern von der Laufzeitumgebung des Prozesses. In C# sieht das dann vereinfacht so aus:
1. | Der Thread wird von der Runtime erzeugt. |
2. | Dieser beginnt mit der Ausführung beim statischen Konstruktor jener Klasse, die die Methode Main() beinhaltet. |
3. | Der Thread ist beendet, sobald die Methode Main() verlassen wurde. |
Für den Entwickler sieht der Ablauf eines Threads so aus, als ob dieser zeitgleich mit anderen Threads läuft. Dies ist aber nur scheinbar so. Da auf einem Rechner beinahe niemals gleich viele CPU-Kerne zur Verfügung stehen wie Threads ausgeführt werden, beinhaltet das Betriebssystem den sg. Scheduler. Dieser verteilt Prozessorzeit für kurze Zeit auf die einzelnen Threads. Dadurch sieht es so aus, als ob die Threads gleichzeitig ablaufen. Wie lang die einzelnen Threads den Prozessor erhalten, hängt von der Priorität ab, den Sie als Programmierer festlegen können. Auf Systemen, die mehr als einen CPU-Kern haben, kann diese Parallelität real sein, da der Scheduler auf jeder CPU einen Thread starten kann.
Vor- und Nachteile
Anwendungen können so umfangreiche Berechnungen als zusätzlichen Thread im Hintergrund ausführen, während der Benutzer weiter über den Haupt-Thread mit der Bedieneroberfläche kommuniziert. Dies trägt dazu bei, dass Anwendungen nicht blockieren, wenn länger andauernde Arbeiten verrichtet werden.
Es entstehen aber nicht nur Vorteile, sondern es gibt auch Besonderheiten (Interferenzen, Deadlocks, Starvation, …) die beachtet werden müssen. Das macht sich insbesondere bei der Fehlersuche bemerkbar. Hatte man bisher nur einen sequentiell ablaufenden Programmfluss, so sind dies nun mehrere.
Einfache Threads
Die einfachste Form eines Threads besteht in der Implementierung einer Methode, deren Anweisungen innerhalb eines Threads ausgeführt werden:
using System; using System.Threading; namespace Threading { class HelloWorld { static void Main(string[] args) { HelloWorld ht = new HelloWorld(); Thread t1 = new Thread(ht.Hello); t1.Name = "Hello Thread A"; t1.Start(); Console.ReadLine(); t1.Abort(); } private void Hello() { Random rnd = new Random (); while (true) { Console.Out.WriteLine(Thread.CurrentThread.Name); Thread.Sleep(rnd.Next(4000)); } } } }
In Zeile 11 wird ein Objekt der Klasse Thread angelegt. Im Konstruktor wird die Methode übergeben, die der Thread abarbeiten soll. In diesem Fall ist es die Methode Hello() der HelloWorld Instanz. Alternativ könnten sie auch einen Delegate vom Typ ThreadStart anlegen und diesen dem Konstruktor übergeben.
Thread t1 = new Thread(new ThreadStart(ht.Hello));
In Zeile 12 bekommt der Thread eine Bezeichnung. Dieses hat auf den Ablauf des Programms keinen Einfluß und dient nur zur Analyse. In Zeile 13 wird der Thread gestartet und der Haupt-Thread wartet auf eine Eingabe. Erfolgt diese, so wird in Zeile 15 der Thread beendet.
Der Thread macht nichts anderes als seinen Namen auf der Konsole auszugeben. Danach wird durch die statische Methode Thread.Sleep() für eine zufällige Zeit gewartet. Während dieser Zeit benötigt der Thread für die angegebene Zeit keine weitere Rechenleistung mehr.
Das Beenden eines Threads durch die Methode Abort() ist nicht die optimalste Lösung. Es könnte sein, dass der Thread gerade mit einer wichtigen Aufgabe beschäftigt ist, die nicht an beliebiger Stelle abgebrochen werden sollte. Wesentlich eleganter ist das Überprüfen einer Abbruchbedingung. Mittels einer Variable, die vom Thread periodisch abgefragt wird, wird diesem mitgeteilt, dass er sich beenden soll.
Wir erweitern das Programm so, dass mit einem Tastendruck der Thread kontrolliert beendet wird:
using System; using System.Collections.Generic; using System.Threading; namespace Threading { class HelloWorld2 { private LinkedList<Thread> threads; private Thread exitThread = null; static void Main(string[] args) { new HelloWorld2(); } public HelloWorld2() { threads = new LinkedList<Thread>(); threads.AddLast(new Thread(this.Hello)); threads.AddLast(new Thread(this.Hello)); int index = 0; foreach (Thread t in threads) { t.Name = String.Format("Thread {0}", index++); t.Start(); } while (threads.Count > 0) { Console.ReadLine(); exitThread = threads.First.Value; exitThread.Join(); threads.RemoveFirst(); } } private void Hello() { Random rnd = new Random(); while (true) { Console.WriteLine("{0} aktiv: {1}", Thread.CurrentThread.Name, rnd.Next()); Thread.Sleep(rnd.Next(4000)); if (exitThread == Thread.CurrentThread) { Console.WriteLine("Ende: {0}", Thread.CurrentThread.Name); return; } } } } }
Für dieses Beispiel habe ich mich dazu entschieden, alle Threads in einer verketteten Liste zu speichern. Als Abbruchkriterium wird die Instanz des Threads selbst benutzt:
1. | In der Methode HelloWorld2() werden zwei Threads angelegt und gestartet. In einer while-Schleife wird auf das Drücken einer Taste gewartet. |
2. | Nach dem Drücken einer Taste wird das erste Thread-Objekt aus der Liste geholt und die Referenz der Instanzvariable exitThread zugewiesen. |
3. | Die Threads durchlaufen eine Endlosschleife. Thread.Sleep() simuliert die eigentliche Arbeit des Threads. Anschließend wird überprüft, ob der Inhalt der Variablen exitThread gleich der eigenen ist. Falls ja, beendet sich der Thread. |
4. | Die Methode Join() in Zeile 30 sorgt dafür, dass der Haupt-Thread so lange wartet, bis der entsprechende Thread sich beendet hat. |
Würde in Zeile 30 der Aufruf der Methode Join() fehlen, so könnte es passieren, dass die Variable exitThread einen neuen Wert zugewiesen bekommt, bevor der Thread in Zeile 41 die Abfrage durchlaufen würde. Join() stellt also sicher, dass der Zugriff auf gemeinsame Resourcen (hier die Instanzvariable exitThread) synchronisiert wird. Da das Synchronisieren eines der wichtigsten Herausforderungen bei der Entwicklung von Multithreading Anwendungen ist, sollen die verschiedenen Methoden genauer vorgestellt werden.
Die Klasse Monitor
Monitore synchronisieren einen bestimmten Abschnitt des Programms. Für diesen Abschnitt wird das betreffende Objekt gesperrt. Solange dieses Objekt von einem Thread beansprucht wird, müssen alle anderen Threads warten. Während der Verwendung eines Monitors kann das Objekt (zeitweise) freigegeben und der Thread in einen Wartezustand versetzt werden. In einer Warteschlange verweilt dieser Thread so lange, bis ein anderer Thread ihn wieder befreit oder die Wartezeit abgelaufen ist. Das Producer/Consumer Beispiel soll die Verwendung der Klasse Monitor verdeutlichen:
using System; using System.Threading; namespace Threading { class ProducerConsumer { static void Main(string[] args) { Item item = new Item(); Producer producer = new Producer(); Consumer consumer = new Consumer(); Thread producerThread = new Thread(producer.Produce); Thread consumerThread = new Thread(consumer.Consume); producerThread.Start(item); consumerThread.Start(item); } } internal class Producer { public void Produce(object i) { Item item = (Item)i; int generation = 0; Random rnd = new Random(); while (true) { Monitor.Enter(item.used); if (item.data != String.Empty) Monitor.Wait(item.used); item.data = String.Format("Ware #{0} ", generation++); Thread.Sleep(rnd.Next(1000)); Console.WriteLine(" Producer erzeugt {0}", item.data); Monitor.Pulse(item.used); Monitor.Exit(item.used); } } } internal class Consumer { public void Consume(object i) { Item item = (Item)i; while (true) { Monitor.Enter(item.used); if (item.data == String.Empty) Monitor.Wait(item.used); Console.WriteLine(" Consumer bekommt {0}", item.data); item.data = String.Empty; Monitor.Pulse(item.used); Monitor.Exit(item.used); } } } internal class Item { public object used = new object(); public string data = String.Empty; } }
Aber sehen wir uns das Beispiel genauer an:
• | In der Methode Main() wird ein Objekt vom Typ Item erzeugt. Dieses dient als Transportmedium der erzeugten Ware. Außerdem wird je ein Objekt der Klasse Producer und Consumer angelegt. Für jedes Objekt wird ein entsprechender Thread erzeugt und gestartet. Als Parameter wird den Threads die Referenz auf item übergeben. |
• | Der Produzent startet in Zeile 28 mit der statischen Methode Monitor.Enter() einen kritischen Abschnitt. Dieses ist notwendig, um den Zugriff auf das Objekt item zu synchronisieren. Ist das Objekt item leer, es wurde also noch keine Ware erzeugt, so wird in Zeile 31 das Feld data gesetzt. Ist Ware vorhanden, so wird in Zeile 30 gewartet, bis der Konsument die Ware verbraucht hat. Ist neue Ware erzeugt, so wird mit der Methode Monitor.Pulse() in Zeile 34 der Konsument informiert. Mit Monitor.Exit() wird der kritische Abschnitt wieder beendet. |
• | Der Konsument startet ebenfalls in Zeile 47 einen kritischen Abschnitt. Wurde noch keine Ware erzeut, so wartet der Konsument in Zeile 48 bis der Produzent den Konsumenten das Erzeugen durch die Methode Monitor.Pulse() signalisiert. In Zeile 49 wird der Inhalt der Ware angezeigt und in Zeile 50 gelöscht. Jetzt wird noch den wartenden Produzenten durch Monitor.Pulse() mitgeteilt, neue Ware zu erzeugen. |
Die Klasse Mutex
Ein Mutex ermöglicht es nicht nur, Threads zu synchronisieren, er ermöglicht auch die Synchronisation von Prozessen. Der Mutex kann aber auch bei Threads benutzt werden – wenn auch auf wesentlich primitiverer Ebene. Im Endeffekt reduziert sich seine Funktionalität darauf, dass sichergestellt wird, dass nur ein Thread einen bestimmten Bereich gleichzeitig ausführt. Sehen wir uns gleich einmal ein Beispiel an:
using System; using System.Threading; namespace Threading { public class MutexTest { public static Mutex MutexLock; public static void Main(string[] args) { MutexLock = new Mutex(); for (int i = 0; i < 10; i++) { Thread t = new Thread( delegate() { while (true) { MutexTest.MutexLock.WaitOne(); Console.WriteLine("aktiver Thread: {0}", Thread.CurrentThread.Name); Thread.Sleep(500); MutexTest.MutexLock.ReleaseMutex(); } } ); t.Name = "Thread - " + i.ToString(); t.Start(); } } } }
In diesem Beispiel führen alle Threads den gleichen Code aus, welcher folgendes macht:
Mit der statischen Methode Mutex.WaitOne() wird der Mutex in Zeile 19 angefordert. In Zeile 21 wird die eigentliche Arbeit simuliert und anschließend wird der Mutex wieder freigegeben.
Die Verwendung von Monitoren ist prinzipiell dem Einsatz von Mutexen vorzuziehen, wenn es sich um die Synchronisiation von Threads handelt. Da Monitore speziell für .NET entwickelt wurden, nutzen sie die Resourcen besser und sind schneller als Mutexe. Mutexe haben ihren Vorteil in der Synchronisation von Prozessen.
Die Klasse AutoResetEvent
Die Methode der Synchronisierung mittels der Klasse AutoResetEvent ist relativ ähnlich der von Mutex. Mit ihr ist es möglich, den Ablauf von parallelen Threads so zu synchronisieren, dass immer nur einer einen spezifischen Codeteil ausführt. Sehen wir uns dazu ein einfaches Beispiel an:
using System; using System.Threading; namespace Threading { class AutoReset { static void Main(string[] args) { Counter cnt = new Counter(); Thread[] cntThreads = new Thread[5]; for (int i = 0; i < cntThreads.Length; i++) { Console.WriteLine(i); cntThreads[i] = new Thread(cnt.Count); cntThreads[i].Name = "Thread - " + i.ToString(); cntThreads[i].Start(); } } internal class Counter { private AutoResetEvent lockVar = new AutoResetEvent(true); public void Count() { while (true) { lockVar.WaitOne(); Console.WriteLine(Thread.CurrentThread.Name); Thread.Sleep(500); lockVar.Set(); } } } } }
Die Methode Main() erzeugt 5 Threads und startet diese. Mit der Methode WaitOne() wird gewartet, bis das AutoResetEvent-Objekt gesetzt ist. Ist es gesetzt, so wird in Zeile 28 der Name des Threads ausgegeben. Die Methode Set() setzt das AutoResetEvent-Objekt, sodaß der nächste Thread freigeschaltet wird. Beim Anlegen des AutoResetEvent-Objektes wurde durch den Konstruktor festgelegt, dass das Objekt zu Anfang gesetzt sein soll. Der erste WaitOne()-Aufruf blockiert den Thread also nicht.
Die Klasse ManualResetEvent
Da das Verhalten des unspezifischen Eintritts oftmals nicht gewünscht ist, wollen wir das Programm so umschreiben, dass jeder Thread in einer definierten Zeit an die Reihe kommt:
using System; using System.Threading; namespace Threading { class AutoReset2 { static void Main(string[] args) { Counter cnt = new Counter(); Thread[] cntThreads = new Thread[15]; cnt.Locks = new AutoResetEvent[cntThreads.Length]; for (int i = 0; i < cntThreads.Length; i++) { cnt.Locks[i] = new AutoResetEvent(false); cntThreads[i] = new Thread(cnt.Count); cntThreads[i].Name = " Thread - " + i.ToString(); cntThreads[i].Start(); } Console.ReadLine(); cnt.StartLock.Set(); } } internal class Counter { public AutoResetEvent[] locks; public ManualResetEvent startLock = new ManualResetEvent(false); private int value = 0; private int idCount = 0; public void Count() { int myId; lock (this) { myId = idCount++; if (myId == 0) locks[0].Set(); } startLock.WaitOne(); while (true) { locks[myId].WaitOne(); for (int i = 0; i < 5; i++) { Console.WriteLine(" {0}: {1}", Thread.CurrentThread.Name, value++); Thread.Sleep(500); } if ((myId + 1) >= Locks.Length) locks[0].Set(); else locks[myId + 1].Set(); } } } }
Neu eingeführt wurde in diesem Beispiel die Klasse ManualResetEvent. Mit dieser warten alle Threads auf den Start. Da wir ansonsten keine Möglichkeit hätten, die korrekte Reihenfolge zu gewährleisten. Es gibt nun ein Array von AutoResetEvent-Objekten. Das Array besitzt genau so viele Einträge wie Threads vorhanden sind. Jeder Thread signalisiert am Ende des geschützten Bereichs immer dem nächsten AutoResetEvent-Objekt, dass der nächste Thread laufen soll. Im Endeffekt kommt dabei ein Zähler heraus, der von verschiedenen Threads hochgezählt wird.