Deadlock und Race Condition

Teil 3: Programmieren für die Multi-Core-CPU

21.05.2008 von Rami Radi
Eine Multi-Core-CPU erzielt durch Parallelverarbeitung von Software höhere Leistung. Doch Vorteile bringt die Multi-Core-CPU nur, wenn auch die Software für die CPU optimiert ist und Multithreading ausnutzt. In dieser mehrteiligen Reihe zur Programmierung für die Multi-Core-CPU führt TecChannel Sie nach und nach durch die Besonderheiten der Multi-Core-Programmierung.

Die Leistung von Computersystemen steigt seit Jahren kontinuierlich an. Dies betrifft nahezu alle Komponenten der Rechner. Am deutlichsten ist der Leistungsgewinn bei den Prozessoren, dem Arbeitsspeicher und den Festplatten zu verfolgen. Insbesondere die Prozessoren haben nicht nur dank höherer Taktung sondern vor allem durch die Multi-Core-Technologie massiv an Leistungsfähigkeit zugelegt. Einzelne Anwendungen profitieren aber nur von Multi-Core, wenn sie entsprechend programmiert sind.

In dieser mehrteiligen Reihe zur Programmierung für Multi-Core-Prozessoren führen wir Sie nach und nach durch die Besonderheiten der Multi-Core-Programmierung. Angefangen bei den wichtigsten Grundlagen der verschiedenen Parallelisierungs-Verfahren in Prozessoren im ersten Teil. Der zweite Teil drehte sich um die notwendige Modularisierung in Applikationen und das .Net-Framework. Jetzt geht es ans Eingemachte: Deadlocks, Race Conditions und Zugriffe auf die .Net-Thread-Implementation. Später folgen noch Informationen über Locking, Delegates, Timer und asynchrone Events.

Zu den Herausforderungen bei mehreren parallel laufenden Threads zählen Deadlocks and Race Conditions. Diese müssen durch saubere, vorausschauende Programmierung verhindert werden. Erstere führen zu hängenden Programmen, letztere zu unvorhersehbaren (und selten richtigen) Ergebnissen.

Deadlocks

Ein Deadlock tritt immer dann auf, wenn zwei (oder mehr) Threads ein Objekt beanspruchen, das jeweils der andere Thread gerade in Besitz hat und blockiert. Deadlocks sind in keiner Weise .Net-spezifisch, sondern können in jeglichen Situationen in der IT auftreten.

Das klassische Beispiel für ein Deadlock sieht wie folgt aus:

Beide Threads blockieren sich also gegenseitig. Keiner kann weiter arbeiten, bis eine Lösung gefunden ist – meist dadurch, dass der Anwender genervt den Taskmanager bemüht und das Programm abschießt.

Vermeidung von Deadlocks

Ein genereller Ansatz zur Vermeidung von Deadlocks liegt darin, alle Objekte immer in einer festen Reihenfolge zu sperren und ferner die Sperrzeiten so gering wie möglich zu halten. Die .Net-Bibliothek stellt darüber hinaus eine Reihe von Timeouts zur Verfügung, die Deadlocks verhindern sollen.

Im folgenden Beispiel versucht jeder Codeabschnitt ein Objekt zu erhalten. Gelingt dies nicht innerhalb von 300 Millisekunden, so liefert Monitor.TryEnter FALSE zurück (Quelle Microsoft).

if (Monitor.TryEnter(this, 300)) {

try {

// Place code protected by the Monitor here.

} finally {

Monitor.Exit(this);

}

} else {

// Code to execute if the attempt times out.

}

Race Conditions

Race Conditions treten immer dann auf, wenn zwei oder mehrere Threads den gleichen Code ausführen und dabei keinen definierten und stabilen Endzustand erreichen. Ein einfaches Beispiel ist jenes, bei dem ein Zähler inkrementiert wird:

Angenommen eine Klasse weist eine Variable vom Typ private static auf. Die Variable wird für jedes erzeugte Objekt um den Wert eins erhöht (in C++: anzahl++ oder anzahl += 1 in Visual Basic). Der Compiler wird dieses Statement in drei CPU-Instruktionen umsetzen: In einen Ladebefehl des Werts in das Register, der Erhöhung des Registers um Eins und einem Zurückspeichern des Registerwertes in die Variable.

Wenn nun in einer multithreaded Applikation ein Thread den Zähler geladen und inkrementiert hat, dann aber durch einen anderen Thread verdrängt wird, bevor er den inkrementierten Zähler in die Variable zurück schreibt, so ist der Wert in der Speicherstelle veraltet und fehlerhaft. Race Conditions treten auch auf, wenn die Reihenfolgen der Operation nicht eingehalten werden. Im Beispiel aus dem ersten Teil dieser Reihe mit der Summenbildung (Sum = C + Z) muss sichergestellt werden, dass die Summenbildung erst dann ausgeführt wird, nachdem die beiden vorherigen Einzelwerte ermittelt wurden. Zwar wird man derlei arithmetische Operationen kaum als unterschiedliche Tasks implementieren, doch das Prinzip gilt natürlich auch dann, wenn statt der Addition (C = A + B) oder Multiplikation (Z = X * Y) Methodenaufrufe stehen, die weitaus komplexere Abläufe umfassen.

Um Fehler dieser Art zu vermeiden, liefern die Hersteller entsprechende Vorkehrungen und Funktionen in ihren Entwicklungswerkzeugen. Race Conditions werden beispielsweise durch die Verwendung der Methoden der Interlocked-Klasse und Methoden wie etwa Interlocked.Increment vermieden. Weitere Hinweise zur Synchronisation von Daten zwischen mehreren Threads sind dem folgenden Dokument zu entnehmen: Synchronizing Data for Multithreading.

Race Conditions treten auch auf, wenn die Aktivitäten mehrerer Threads synchronisiert werden müssen. Auch hier gilt es sicherzustellen, dass der Code, der zuerst abgeschlossen sein muss, auch tatsächlich durchlaufen ist, bevor der davon abhängige Thread zur Ausführung gelangt. Im Prinzipiellen gilt es dabei immer darauf zu achten, was passiert, wenn ein Thread durch den Scheduling-Mechanismus unterbrochen wird und nicht zur Ausführung gelangt.

Multithreading in .NET

Obgleich das .Net-Framework von Microsoft für multithreaded Umgebungen ausgelegt ist, so gilt das natürlich nur für das Framework. Um seinen eigenen Code muss sich der Entwickler bei der Programmierung jedoch selbst kümmern. Threading, also die Programmierung von parallelen Applikationen, ist auch in .Net keine einfache Angelegenheit.

Wenn bis dato immer von der Softwareentwicklung im Allgemeinen gesprochen wurde, so gilt das genauso für den gesamten Entwicklungsprozess, also die Modellierung, die Codierung und auch den begleitenden Module- oder Integrationstest. Beachtet werden muss ferner, dass derzeit nahezu alle, den Entwicklungsprozess begleitenden Hilfen, von streng sequentiell abgearbeiteten Programmen ausgehen.

Suche nach parallelisierbaren Codeabschnitten

Der erste Schritt in der Entwicklung von parallel laufenden Programmen ist die Suche nach all den Codestücken, die parallel abgearbeitet werden können. Hierbei lassen sich meist zwei Techniken der Parallelisierung herausbilden, die Task Parallelisierung und die Daten Parallelisierung.

Bei der Task Parallelisierung wird der Code in zwei oder mehr unabhängige Tasks zerlegt. Diese werden zur Laufzeit als zwei oder mehr unabhängige Threads oder Prozesse abgearbeitet. Als Beispiel dafür mag die Rechtschreibkorrektur des Testsystems gelten. Bezogen auf ein Bestellsystem könnte das Laden der Kundenstammdaten nach der Eingabe der Kundennummer als eigener Task ausgeführt sein.

Bei der Daten Parallelisierung geht man davon aus, dass die durch den Task bearbeiteten Daten völlig separiert sind. Ein Beispiel dafür ist das Rendering von Graphiken oder aufwändigen Bildschirminhalten.

Die Parallelisierung von Code darf aber auch nicht auf eine bestimmt Anzahl CPUs eingeschränkt werden. Vielmehr muss der Code so ausgelegt sein, dass er eine beliebige Anzahl an CPUs nutzen kann. Dies ist deswegen notwendig, da zukünftige CPUs mit noch mehr Kernen ausgestattet sein werden. Die Grenzen der Tasks müssen also dynamisch bestimmt werden. Zumindest aber sollte der Task zur Laufzeit ermitteln, ob es überhaupt sinnvoll ist, ihn auf mehrere CPU-Kerne zu verteilen und parallel auszuführen. Doch dazu müssen bereits im Code die Vorkehrungen eingebaut sein.

Die Thread Verwaltung in .Net

Das .Net-Framework selbst hat keine Thread-Verwaltung, sondern stützt sich hierbei auf die Funktionen des Betriebssystems. Die Windows-Betriebssysteme setzen dazu einen Scheduler ein. Dessen Aufgabe ist die Zuweisung und Verwaltung der Threads an die CPUs.

Auf einem Rechnersystem mit nur einer Single-Core-CPU erfolgt die Verteilung im Time-Slicing-Verfahren. Dabei erfolgt die Zuweisung der CPU an die vielen Tasks nacheinander und immer für eine kurze Zeitspanne (die Zeitscheibe). Unter Windows XP liegen diese Zeitscheiben meist im Millisekunden-Bereich. Letztendlich kann aber natürlich immer nur ein Task zur Zeit aktiv sein.

In einer Multi-Core- oder Multiprozessor-Umgebung allerdings erfolgt die Zuweisung der Tasks an die Kerne in einer etwas komplexeren Variante. Die Grundlage dabei stellt auch das Zeitscheibenverfahren dar. Hinzu kommen aber weitere Logiken, die eine echte Parallelverarbeitung ermöglichen.

Zwei Arten des Multithreading in .NET

Die Applikationen werden zur Laufzeit als managed Threads in .Net abgebildet. Managed Threads können in zwei Ausprägungen auftreten. Sie sind entweder vom Typ Vordergrund-Thread (Foreground Thread) oder Hintergrund-Thread (Background Thread). Eine Applikation in einer durch .Net gemanagten Umgebung benötigt mindestens einen Foreground Thread. Mit dessen Ende, endet auch die Applikation.

Background Threads wiederum können eine Applikation nicht am Leben halten. Wird die Applikation beendet, so werden auch die Background Threads gelöscht. Standardmäßig werden Threads als Vordergrund-Thread erzeugt. Die Property Thread.IsBackground liefert Auskunft darüber, um welchen Thread-Typ es sich handelt.

Generell können .Net-Applikationen auf zwei Arten parallelisiert werden: entweder Sie erzeugen die Threads explizit durch die Programmlogik oder durch lassen das Framework die Threads implizit erzeugen. In den folgenden Erläuterungen werden die Techniken der .Net-Programmierung und Multithreading weiter detailliert.

Der CLR-Thread Pool

Das Erzeugen (etwa 200000 cycles) und Löschen (etwa 100000 cycles) von Threads ist ein vergleichsweise zeitaufwändiger Vorgang. Für kurz laufende Prozesse würde der Verwaltungs-Overhead übermäßig zu Buche schlagen und den Gewinn durch die Parallelisierung zunichte machen.

Daher versucht man, möglichst selten Threads zu erzeugen oder zu löschen. Hierzu verwaltet die CLR intern einen Pool an so genannten Worker Threads. Der Entwickler interagiert mit diesem Pool. Er übergibt seine Threads an diesen Pool zur Verwaltung. Dazu steht ihm der Klassentyp ThreadPool aus dem Namensraum System.Threading zur Verfügung. Dieser Thread Pool kann er verwenden, um beispielsweise Arbeitsaufträge, Timer oder asynchrone I/O-Operationen zu hinterlegen und kontrolliert durch .Net ausführen zu lassen. Um eine Methode unter der Kontrolle des Frameworks ausführen zu lassen, übergibt der Entwickler die Methode an den Pool. Dazu steht ihm die Methode ThreadPool.QueueUserWorkItem() zur Verfügung. Die übergebene Methode wird ausgeführt, sobald ein Thread im Pool verfügbar ist.

Durch das Überladen (overloading) der Methode mit einer anwendungsspezifischen Methode und dem System.Object erfolgt der Verweis auf den Anwendungs-Code.

public static bool QueueUserWorkItem(WaitCallback callBack);

public static bool QueueUserWorkItem(WaitCallback callBack, object state);

Der Ablauf im Einzelnen

Das Applikationsprogramm macht einen Aufruf an den Thread Pool und übergibt die Methode an den Pool. Danach wird das Applikationsprogramm weiter ausgeführt. Es handelt sich somit um einen asynchronen Aufruf des Thread Pool. Der Thread im Thread Pool läuft unabhängig und getrennt vom Applikationscode.

Nach der Beendigung der Arbeit signalisiert der Thread im Pool das Ende an den Aufrufer im Applikations-Thread. Dies passiert durch den WaitCallback Delegate.

Die Methode WaitCallback verweist ihrerseits auf eine Methode, die als Parameter ein System.Object beinhaltet. Dieser Code wird nach der Arbeit des asynchronen Threads aufgerufen.

Ablauf: Die Applikation übergibt die auszuführende Methode an den Thread Pool und macht dann weiter.

Der folgende Codeabschnitt demonstriert die Hinterlegung von Methoden zur Ausführung durch den Worker-Pool. Die CallBack-Methode computeOne wird nach Beendigung der Threads im Pool aufgerufen:

class Program {

static void Compute(int[] list) {

WaitCallback workItem = new WaitCallback(computeOne);

foreach (int x in list) {

ThreadPool.QueueUserWorkItem(workItem, x);

}

Console.WriteLine("All tasks queued in Pool now");

}

static void computeOne (object state) {

int i = (int)state;

// do necessary computation on i

}

...

}

Verwaltung von Applikations-Threads

Die Verwaltung der Applikations-Threads durch den Thread Pool stellt nur eine von mehreren Varianten dar. Daneben könnte der Entwickler seine Applikations-Threads natürlich auch selbständig erzeugen und verwalten.

Die Nutzung des CLR Pools bietet aber entscheidende Vorteile: Sie entbindet den Entwickler von der expliziten Verwaltung der Threads und den notwendigen Start-, Stopp- oder Create- und Destroy-Operationen. Um all das kümmert sich nun die CLR von .Net. Dies erhöht auch die Effizienz in der gesamten Verwaltung.

Dennoch kommt der Entwickler nicht gänzlich um die eigene Thread-Verwaltung herum. Die Threads im Pool laufen immer als Hintergrund-Thread. Dementsprechend muss der Entwickler Threads, die im Vordergrund laufen sollen, selbst verwalten. Ferner erhalten die Threads im Pool eine Standardpriorität. Soll ein Thread eine andere Priorität aufweisen, muss er ebenso durch den Entwickler verwaltet werden.

Im .Net Framework Version 2.0 umfasst der Thread Pool standardmäßig 25 Worker Threads pro Prozessor and 1000 I/O Threads. Durch die Methode SetMaxThreads lässt sich die Anzahl der Threads im Pool aber verändern. Jeder Thread im Pool erhält außerdem einen Stack und eine Standardpriorität zugewiesen.

Zusammenspiel Vordergrund- und Hintergrund-Thread

Wenn die Applikation und ihre Vordergrund-Threads automatisch beendet werden sollen, sobald die Hintergrund-Threads ihre Arbeit erledigt haben, muss der Entwickler eigene Mechanismen dafür einsetzen, denn Hintergrund-Threads bleiben solange aktiv, wie der Vordergrund-Thread aktiv ist.

Somit kann also der Vordergrund-Thread nicht auf den Hintergrund-Thread und dessen Ende warten, um selbst die Arbeit einzustellen. Damit der Vordergrund-Thread dennoch erkennen kann, ob der Hintergrund Thread seine Arbeit erledigt hat, müssen Wait Handle Signale ausgetauscht werden.

Der BackgroundWorker

Manche Operationen, wie etwa der Download von Daten oder Datenbank-Transaktionen erfordern eine relative lange Verarbeitungszeit. Damit der Benutzer nicht den Eindruck einer „hängenden“ und nicht mehr reagierenden Applikation bekommt, sollten diese Aktionen im Hintergrund ausgeführt werden.

Dies kann durch den BackgroundWorker erfolgen. Er bietet somit eine Möglichkeit, zeitaufwändige Operationen asynchron (im Hintergrund) und im Kontext eines eigenen Threads auszuführen. Um den BackgroundWorker zu nutzen, ist diesem durch den Aufruf von RunWorkerAsync lediglich die Methode zu übergeben.

Der Applikations-Thread läuft nach dem Aufruf von RunWorkerAsync weiter, parallel dazu wird nun der BackgroundWorker den übergebenen Thread ausführen. Ist dieser beendet, so signalisiert er dies dem Applikations-Thread durch das Auslösen eines Events (RunWorkerCompleted). Dabei kann er auch den Rückgabewert des Threads zurückreichen. Implementiert ist der BackgroundWorker als eine Helfer-Klasse im Namespace System.ComponentModel.

Funktionen des BackgroundWorker

Diese Klasse stellt folgende Funktionen bereit:

Hierbei sind vor allem die letzten beiden Funktionen hilfreich. Damit entfallen nämlich die ansonsten notwendigen “try/catch-Blöcke” in der Worker-Methode. Auch der in früheren Versionen von .Net notwendige Aufruf der Methode Control.Invoke wird dadurch überflüssig. Stattdessen erfolgt die Anpassung der Windows Forms und WPF aus dem BackgroundWorker heraus.

BackgroundWorker nutzt Thread Pool

Die Erzeugung und Löschung von Threads ist wie bereits geschildert sehr zeitaufwändig. Daher operiert auch die BackgroundWorker Klasse mit dem CLR Thread Pool und recycled die Threads. Aus diesem Grunde sollten Sie den Aufruf von Abort vermeiden, denn das würde den Thread beenden.

Die notwendigen Schritte für den Gebrauch des BackgroundWorker sind folglich:

Das folgende Beispiel zeigt den Einsatz des BackgroundWorker. Ein Argument, das an RunWorkerAsync übergeben wird, reicht dieser an den Event-Handler von DoWork weiter:

class Program {

static BackgroundWorker bw = new BackgroundWorker();

static void bw_DoWork(object sender, DoWorkEventArgs e) {

// This is called on the worker thread

Console.WriteLine(e.Argument);

// Perform time-consuming task...

}

static void Main() {

bw.DoWork += bw_DoWork;

bw.RunWorkerAsync("Message to worker");

}

...

}

Feedback an den User

Der BackgroundWorker arbeitet still im Hintergrund. Damit der Benutzer jedoch nicht den Eindruck einer hängenden Applikation bekommt, sollte regelmäßig Feedback gegeben werden. Folgende Aktionen sind möglich und notwendig:

Setzen des Attributs WorkerReportsProgress auf true.

Ein periodischer Aufruf von ReportProgress aus dem DoWork-Eventhandler mit dem Wert "percentage complete" und falls gewünscht einem user-state object.

Bearbeiten des Event ProgressChanged und Abrufen der Event-Argumente in das Attribut ProgressPercentage.

Durch das Event RunWorkerCompleted signalisiert der BackgroundWorker der Applikation, wenn der DoWork Eventhandler seine Arbeit erledigt hat. Die Behandlung des Events RunWorkerCompleted ist optional. Als Reaktion können auch Forms and WPF controls akualisiert werden.

Im nächsten Teil der Serie beschäftigen wir uns mit den Besonderheiten der Programmierung, wenn mehrere Threads auf gemeinsame Daten zugreifen sollen, und dem Datenaustausch zwischen Thread und Applikation. (mha)