Nachdem es in den ersten beiden Teilen um die Grundlagen und die Anwendung von Threads ging, soll es im dritten Teil konkret um die Gefahren bei der Benutzung von Threads gehen.
Interferenzen
Eine Interferenz kann zustande kommen, wenn mehrere Threads gleichzeitig auf die gleichen Daten (Objekte) zugreifen. Ein Thread könnte eine Methode eines Objektes in einem ungültigen Zustand hinterlassen, wenn das System ihm mitten in der Ausführung die Zeitscheibe entzieht und der nächste Thread mit derselben Methode desselben Objektes zu arbeiten beginnt. Der Thread, der den Objektzustand von seinem Vorgänger übernommen hat, produziert dann möglicherweise falsche Ergebnisse.
In der Dokumentation der .NET-Klassenbibliothek wird in diesem Zusammenhang auch der Begriff der Threadsicherheit genannt. Darunter versteht man, dass ein Objekt auch dann in einem gültigen Zustand bleibt, wenn auf dieses von mehreren Threads gleichzeitig zugegriffen wird. Im Umkehrschluss kann man auch sagen, dass Threadsicherheit nichts anderes bedeutet, als das mehrere Threads gleichzeitig dieselbe Methode desselben Objekts aufrufen dürfen, ohne dass es zu undefinierten Zuständen kommt.
Im Folgenden ist ein Beispiel, das genau die Situation einer Interferenz darstellt.
public class Counter { private int value; public void increase() { value++; } public void decrease() { value--; } public int Value { get { return value; } } }
Die Klasse sieht auf den ersten Blick richtig und ungefährlich aus. Es wird aber schnell klar, dass diese Klasse eben nicht Threadsicher ist. Die Probleme können auftreten, wenn zwei Threads auf dieselbe Instanz dieser Klasse zugreifen. Beide Threads lesen erst den Wert. Der eine Thread ruft die Methode increase() auf und der andere Thread die Methode decrease(). Welchen Wert hat nach Beenden der beiden Threads die Eigenschaft Value? Das lässt sich nicht voraussagen, da die Befehle value– und value++ nicht atomar sind, d.h. sie bestehen aus mehreren Assemblerbefehlen und können dementsprechend mitten in der Ausführung von anderen Threads unterbrochen werden. Die Instanz befindet sich dann in einem undefinierten Zustand.
Wie kann man Interferenzen nun aber vermeiden? Im Prinzip relativ einfach: Man muss als Entwickler darauf achten, dass Ressourcen immer nur von einem Thread gleichzeitig verwendet werden. Dies geschieht mithilfe von Synchronisierung. In späteren Bereichen werden wir uns mit den von .NET zur Verfügung gestellten Mitteln noch genauer beschäftigen.
Deadlocks
Ein Deadlock entsteht dann, wenn zwei Threads gegenseitig auf sich warten. Dies kann beispielsweise dann geschehen, wenn zwei Threads Exklusivrechte auf zwei Ressourcen besitzen:
1. | Thread 1 holt sich exklusiven Zugriff auf Ressource A |
2. | Thread 2 holt sich exklusiven Zugriff auf Ressource B |
3. | Thread 1 benötigt Zugriff auf Ressource B und wartet auf die Freigabe durch Thread 2 |
4. | Thread 2 benötigt Zugriff auf Ressource A und wartet auf die Freigabe durch Thread 1 |
In diesem Fall blockieren sich beide Threads gegenseitig, da die Bedingungen niemals erfüllt werden können. Das Vermeiden von Deadlocks kann eine aufwendige Sache werden, auf die der Entwickler besonders achten sollte. Eine wichtige Grundregel zur Vermeidung solcher Situationen ist:
• | Ein Bereich darf maximal eine geschützte Ressource haben |
Somit sollte folgendes vermieden werden:
lock (lock1) { // Bereich 1 mit weiteren Befehlen if (condition) { lock (lock2) { // Bereich 2 mit weiteren Befehlen } } }
Ein Deadlock kann hierbei schnell auftreten, wenn ein weiterer Thread lock2 hält und gleichzeitig lock1 anfordert.
Verhungern
Wenn ein Thread bei der Benutzung von Ressourcen so benachteiligt wird, dass dieser niemals seine Arbeit verrichten kann, dann spricht man von Verhungern. Vorkommen kann dieses, wenn z.B. in einem geschützten Bereich sehr langwierige Arbeiten verrichtet werden und dementsprechend der Lock nicht freigegeben wird. Als Beispiel sehen wir uns folgende Klasse an:
namespace Threading { public class Starvation { private object lockObject = new object(); private double pi; static void Main(string[] args) { new Starvation(); } public Starvation() { Thread hungryThread = new Thread(new ThreadStart( delegate () { while (true) { lock (lockObject) { // aufwendige Rechenoperation ... pi = CalculatePi(); } Console.WriteLine(pi); } } )); hungryThread.Start(); // jede Sekunde den Status der Rechenoperation ausgeben while (hungryThread.IsAlive) { Thread.Sleep(1000); lock (lockObject) { Console.WriteLine("Calculating ..."); } } } private double CalculatePi() { double radius = 1000; double kreistreffer = 0; for (double y = radius * (-1); y <= radius; y++) { double end = Math.Pow( radius , 2 ); for (double x = radius * (-1); x <= end; x++) { if ((Math.Pow(x, 2) + Math.Pow(y, 2)) <= Math.Pow(radius, 2)) { kreistreffer = kreistreffer + 1; } } } return kreistreffer / Math.Pow(radius, 2); } } }
Der Thread hungyThread benötigt eine lange Zeit, um die Operation im geschützten Bereich auszuführen. Währenddessen kann natürlich kein anderer Thread die Ressource nutzen und verhungert. Damit das Verhungern unterbunden wird, sollte man grundsätzlich darauf achten, dass ein geschützter Block keine lang andauernden Operationen durchführt.
Es kann auch bei der Benutzung von Priorietäten dazu kommen, dass ein Thread verhungert. Nehmen wir an, dass auf ein Datenbank-System immer nur ein Schreiber, aber eine beliebige Anzahl von Lesern zugreifen darf. Werden die Lese-Threads alle hoch priorisiert und es liegen viele Leseanfragen an, so hat der Schreib-Thread kaum die Möglichkeit, seine Arbeit zu verrichten.
Wichtig ist es zu wissen, dass beim Verhungern, im Gegensatz zu einem Deadlock, der Thread theoretisch doch einmal an die Reihe kommen kann, es aber praktisch nie tut.
Ausblick
Mit dem .NET Framework 4 wird dem Softwareentwickler eine weitere Möglichkeit geboten, Multithreading Anwendungen zu programmieren. Die Task Parallel Library (TPL) vereinfacht die Thread-Erzeugung und Synchronisierung deutlich. Dieses ist auch notwendig, da mittlerweile fast auf jedem Schreibtisch ein Computer mit einem Multi-Core Prozessor steht. Will man diesen effektiv nutzen, so war bisher mühsames Programmieren von Multithreading notwendig. Es musste genauestens festgelegt werden, “wie” eine Aufgabe parallelisiert werden soll. Im Gegensatz hierzu wird bei der TPL festgelegt, “was” asynchron abgearbeitet werden soll. Ein Blick in den Namespace System.Threading.Tasks kann ich an dieser Stelle sehr empfehlen.