Was jeder Entwickler über Multithread-Anwendungen wissen muss

Veröffentlicht: 10. Okt 2005

Von Vance Morrison

Themen in diesem Artikel:

  • Multithreading und das Threadingmodell für den gemeinsamen Speicher

  • Races und das Zerstören von Invarianten durch gleichzeitigen Zugriff

  • Sperren als Standardlösung für Races

  • Wann Sperren benötigt werden

  • Verwenden von Sperren und Verstehen der Leistungseinbußen

  • Gegenseitige Behinderung der Sperren

In diesem Artikel werden die folgenden Technologien verwendet:.NET Framework

Vor zehn Jahren befassten sich nur eingefleischte Systemprogrammierer mit den Schwierigkeiten, korrekten Code unter Verwendung mehrerer Ausführungsthreads zu schreiben. Die meisten Programmierer blieben bei der sequenziellen Programmierung, um das Problem überhaupt zu vermeiden. Heute jedoch finden Multiprozessorcomputer immer mehr Verbreitung. Bald werden Programme ohne Multithreading im Nachteil sein, da sie einen großen Teil der verfügbaren Rechenleistung nicht nutzen können.

Leider ist es nicht einfach, einwandfreie Multithread-Programme zu schreiben. Die Programmierer sind einfach nicht an die Vorstellung gewohnt, dass der Speicher durch andere Threads geändert werden kann. Schlimmer noch: Trotz eines Fehlers ist das Programm die meiste Zeit noch funktionsfähig. Programmfehler zeigen sich nur unter hoher Last (im Produktionseinsatz), und nur selten stehen am Fehlerpunkt genügend Informationen zur Verfügung, um die Anwendung wirksam debuggen zu können. In Tabelle 1 sind die wichtigsten Unterschiede zwischen sequenziellen und Multithread-Programmen zusammengefasst. Daran wird deutlich, dass es sich wirklich lohnt, Multithread-Programme von Anfang an fehlerfrei zu schreiben.

Tabelle 1: Merkmale von sequenziellen und Multithread-Programmen

Sequenzielle Programme

Multithread-Programmme

Nutzen nur einen einzigen Prozessor

Nutzen Hardware mit mehreren Prozessoren

Verhalten sich deterministisch

Verhalten sich nicht-deterministisch

Die meisten Bugs werden beim Testen des kompletten Codes gefunden

Das Suchen des Codes nach Bugs reicht nicht aus, da die schwersten Fehler auftreten, wenn die Threads versuchen, auf den Speicher zuzugreifen

Die Hauptursache für Fehler werden durch das Tracing des ausführenden Befehls gefunden, der zu dem Fehler führt

Hauptursachen werden durch die Annahme eines Races gefunden, das das Ergebnis verursacht hat, oder durch methodische Überprüfung des Codes nach möglichen Ursachen

Ein Fehler kann grundsätzlich behoben werden, wenn er identifiziert ist

Die Ursache für ein Race kann durchaus unentdeckt bleiben

Mit diesem Artikel verfolge ich drei Ziele. Zunächst möchte ich zeigen, dass Multithread-Programme nichts Geheimnisvolles haben. Für einwandfreie sequenzielle Programme und für Multithread-Programme gilt die gleiche grundlegende Anforderung: Der gesamte Programmcode muss Invarianten schützen, die in anderen Teilen des Programms benötigt werden. Zweitens werde ich zeigen, dass dieses Prinzip zwar sehr einfach ist, dass der Schutz von Programminvarianten beim Multithreading jedoch wesentlich schwieriger ist. Beispiele, die in einer sequenziellen Umgebung trivial sind, erweisen sich in einer Multithread-Umgebung als überraschend kompliziert. Abschließend werde ich außerdem zeigen, wie Sie kleinere Probleme bewältigen, die sich in Ihre Multithread-Programme einschleichen können. Diese Empfehlung läuft auf eine sehr methodische Vorgehensweise beim Schutz von Programminvarianten hinaus. Tabelle 2 zeigt, dass dies bei Multithread-Anwendungen komplizierter ist. Diese Komplexität des Multithreadings hat verschiedene Gründe, die ich in den folgenden Abschnitten erläutern werde.

Tabelle 2: Programmieren für sequenzielle und Multithread-Programme

Sequenzielle Programme

Multithread-Programmme

Speicher ist dauerhaft, es sei denn, es erfolgt ein explizites Update

Speicher ist im Fluss. Ausnahmen sind Read-only, ein lokaler Thread oder der Schutz durch eine Sperre.

Keine Sperren notwendig

Sperren sind notwendig

Invarianzen in der Datenstruktur müssen bei Beginn und Ende der Datenstruktur-Methoden andauern

Invarianzen in der Datenstruktur müssen immer dann andauern, wenn die Sperre, die die Datenstruktur schützt, gestört ist

Keine Vorkehrungen sind notwendig, wenn man auf zwei voneinander abhängige Datenstrukturen zugreift

Um zwei Datenstrukturen miteinander verbinden zu können, müssen vorher Sperren für beide Strukturen bestehen

Deadlock ist nicht möglich

Deadlock tritt auf, wenn es mehrere und ungeordnete Sperren gibt

Threads und Speicher

Die Multithread-Programmierung scheint im Grunde genommen ziemlich einfach zu sein. Die Anweisungen werden nicht nur von einer Verarbeitungseinheit sequenziell ausgeführt, sondern von mindestens zwei Verarbeitungseinheiten gleichzeitig. Da es sich bei den Prozessoren um wirkliche Hardware handeln kann oder die Prozessoren durch Zeitmultiplexing eines einzelnen Prozessors implementiert werden, wird anstelle des Begriffs "Prozessor" der Begriff "Thread" verwendet. Verzwickt an der Multithread-Programmierung ist die Kommunikation der Threads untereinander.

Threadingmodell für den gemeinsamen Speicher
Abbildung 3: Threadingmodell für den gemeinsamen Speicher

Das am häufigsten eingesetzte Multithread-Kommunikationsmodell ist das Modell des gemeinsam genutzten Speichers. In diesem Modell greifen alle Threads auf den gleichen Pool gemeinsamen Speichers zu, wie in Abbildung 3 dargestellt ist. Dieses Modell bietet den Vorteil, dass Multithread-Programme im Wesentlichen genauso programmiert werden wie sequenzielle Programme. Dieser Vorteil ist jedoch zugleich das größte Problem dieses Modells. Das Modell unterscheidet nicht zwischen Speicher, der ausschließlich für lokale Threads verwendet wird (wie die meisten lokal deklarierten Variablen), und Speicher, der für die Kommunikation mit anderen Threads verwendet wird (wie bestimmte globale Variablen und Heapspeicher). Da gemeinsam verwendeter Speicher wesentlich sorgfältiger behandelt werden muss als Speicher, der nur lokal in einem Thread verwendet wird, können sich sehr leicht Fehler einschleichen.

Races

Stellen Sie sich ein Programm vor, das Anforderungen verarbeitet und über den globalen Zähler totalRequests verfügt, dessen Wert nach der Ausführung jeder Anforderung inkrementiert wird. Wie Sie sehen, ist der entsprechende Code für ein sequenzielles Programm trivial:

totalRequests = totalRequests + 1

Wenn das Programm jedoch über mehrere Threads verfügt, die Anforderungen verarbeiten und totalRequests aktualisieren, gibt es ein Problem. Der Compiler kompiliert den Inkrementierungsvorgang in Form des folgenden Computercodes:

MOV EAX, [totalRequests]  // Speicher für totalRequests in Register laden
INC EAX                   // Register aktualisieren
MOV [totalRequests], EAX  // aktualisierten Wert in den Speichern zurückspeichern

Stellen Sie sich vor, was geschieht, wenn dieser Code von zwei Threads gleichzeitig ausgeführt wird. Wie in Abbildung 4 dargestellt, laden beide Threads den gleichen Wert für totalRequests, beide inkrementieren den Wert des Zählers, und beide speichern wieder den Zähler totalRequests. Am Ende dieser Sequenz haben zwei Threads eine Anforderung verarbeitet, der Wert in totalRequests ist jedoch nur um eins größer als zuvor. Das ist natürlich nicht beabsichtigt. Fehler dieser Art, die aufgrund von Timingfehlern zwischen den Threads entstehen, werden Races (Wettrennen) genannt.

Anatomie eines Race
Abbildung 4: Anatomie eines Race

Dieses Beispiel ist vielleicht etwas vereinfacht, die allgemeine Struktur des Problems ist jedoch auch in der Praxis bei komplizierteren Races die gleiche. Für ein Race müssen vier Bedingungen erfüllt sein.

Die erste Bedingung: Man kann auf bestimmte Speicherbereiche von mehreren Threads aus zugreifen. In der Regel sind diese Bereiche globale/statische Variablen (wie bei totalRequests) oder Heapspeicher, der über globale/statistische Variablen erreicht werden kann.

Die zweite Bedingung: Mit diesen gemeinsamen Speicherbereichen ist eine Eigenschaft verknüpft, die für das einwandfreie Funktionieren des Programms benötigt wird. In diesem Fall ist gibt totalRequests genau wieder, wie oft ein Teil der Inkrementierungsanweisung durch einen Thread ausgeführt wurde. Normalerweise muss die Eigenschaft vor einer Aktualisierung gelten (d. h. totalRequests muss eine genaue Anzahl enthalten), damit die Aktualisierung richtig ist.

Die dritte Bedingung: Die Eigenschaft bleibt während einer bestimmten Phase der eigentlichen Aktualisierung nicht gleich. In diesem speziellen Fall erfüllt totalRequests in dem Zeitraum zwischen dem Abrufen des Werts von totalRequests bis zum Zeitpunkt der Speicherung die Invarianzbedingung nicht.

Die vierte Bedingung für ein Race: Ein anderer Thread greift auf den Speicher zu, während die Invarianz zerstört ist und so das fehlerhafte Verhalten verursacht.

Sperren

Races werden normalerweise durch Sperren verhindert. Andere Threads werden dabei daran gehindert, auf den einer Invariante zugeordneten Speicher zuzugreifen, während die Invariante zerstört ist. Damit wird die erwähnte vierte Bedingung beseitigt, so dass ein Race unmöglich wird.

Für die am häufigsten verwendete Sperre gibt es viele verschiedene Bezeichnungen. Sie wird gelegentlich als Monitor, kritischer Abschnitt, Mutex oder als binärer Semaphor bezeichnet, erfüllt jedoch unabhängig von ihrem Namen stets die gleiche grundlegende Funktion. Die Sperre enthält Enter- und Exit-Methoden. Sobald ein Thread Enter aufruft, bewirken alle Versuche anderer Threads, Enter aufzurufen, dass die anderen Threads blockiert werden (warten müssen), bis für die Sperre Exit aufgerufen wird. Der Thread, von dem Enter aufgerufen wurde, ist Eigentümer der Sperre, und es ist ein Programmierfehler, wenn Exit von einem Thread aufgerufen wird, der nicht Eigentümer der Sperre ist. Mithilfe von Sperren lässt sich sicherstellen, dass immer nur ein Thread einen bestimmten Codebereich ausführen kann.

In Microsoft.NET Framework werden Sperren durch die System.Threading.Monitor-Klasse implementiert. Die Monitor-Klasse ist ein wenig ungewöhnlich, da sie keine Instanzen definiert. Dies liegt daran, dass die Sperrfunktion tatsächlich durch System.Object implementiert wird. Jedes Objekt kann daher als Sperre fungieren. So vermeiden Sie mithilfe einer Sperre das mit totalRequests verknüpfte Race:

static object totalRequestsLock = new Object();  // wird zur Programm-
                                                 // initialisierung ausgeführt
...
System.Threading.Monitor.Enter(totalRequestsLock);
totalRequests = totalRequests + 1;
System.Threading.Monitor.Exit(totalRequestsLock);

Zwar wird mit diesem Code das Race vermieden, allerdings kann dadurch ein anderes Problem entstehen. Wenn eine Ausnahme auftritt, während die Sperre aktiviert ist, wird Exit nicht aufgerufen. Daraufhin werden alle übrigen Threads, die diesen Code auszuführen versuchen, auf Dauer blockiert. In vielen Programmen wäre jede Ausnahme ein schwerwiegender Fehler. Was in einem solchen Fall geschieht, ist daher nicht interessant. Bei Programmen, für die nach Ausnahmen eine Wiederherstellung möglich sein soll, kann die Lösung allerdings robuster gestaltet werden, indem der Aufruf von Exit in eine finally-Klausel eingefügt wird:

System.Threading.Monitor.Enter(totalRequestsLock);
try {
    totalRequests = totalRequests + 1;
} finally {
    System.Threading.Monitor.Exit(totalRequestsLock);
}

Dieses Muster ist so häufig, dass es sowohl in C# als auch in Visual Basic .NET ein spezielles Anweisungskonstrukt gibt, das es unterstützt. Der folgende C#-Code entspricht der soeben dargestellten try/finally-Anweisung:

lock(totalRequestsLock) {
    totalRequests = totalRequests + 1;
}

Ich persönlich bin in Bezug auf die Sperranweisung im Zwiespalt. Einerseits ist sie eine bequeme Kurzform, andererseits kann sie Programmierern jedoch das falsche Vertrauen einflößen, dass sie robusten Code schreiben. Denken Sie daran: Der gesperrte Bereich wurde eingeführt, da in ihm eine wichtige Programminvariante zerstört wurde. Wenn in diesem Bereich eine Ausnahme ausgelöst wird, war die Invariante zum Zeitpunkt der Auslösung der Ausnahme wahrscheinlich zerstört. Es ist nicht sinnvoll, die Fortsetzung des Programms ohne einen Versuch zu ermöglichen, die Invariante wiederherzustellen.

Im totalRequests-Beispiel ist keine sinnvolle Bereinigung möglich, daher ist die Sperranweisung geeignet. Die Sperranweisung wäre ebenfalls geeignet, wenn alle in ihr ausgeführten Befehle nur lesend zugreifen würden. Im Allgemeinen ist jedoch mehr Bereinigungsarbeit erforderlich, wenn eine Ausnahme auftritt. In diesem Fall ist eine Sperranweisung nicht sehr hilfreich, da auf jeden Fall eine explizite try/finally-Anweisung benötigt wird.

Richtiges Verwenden von Sperren

Die meisten Programmierer hatten bereits mit Races zu tun und kennen einfache Beispiele dafür, wie sie sich mit Sperren verhindern lassen. Ohne zusätzliche Erläuterung ergeben sich aus den Beispielen selbst jedoch keine wichtigen Prinzipien, die in der Praxis für den wirksamen Einsatz von Sperren in Programmen benötigt werden.

Die erste wichtige Beobachtung ist, dass Sperren zwar den wechselseitigen Ausschluss von Codebereichen ermöglichen, Programmierer jedoch im Allgemeinen Speicherbereiche schützen möchten. Im totalRequests-Beispiel ist es das Ziel sicherzustellen, dass eine Invariante in totalRequests (einem Speicherbereich) zuverlässig den korrekten Wert enthält. Dazu fügen Sie jedoch eine Sperre um einen Codebereich (für das Inkrementieren von totalRequests) ein. Dies ermöglicht den wechselseitigen Ausschluss für totalRequests, da nur dieser Code auf totalRequests verweist. Falls totalRequests von anderem Code ohne Verwendung der Sperre aktualisiert wird, gibt es keinen wechselseitigen Ausschluss für den Speicher, und folglich würde der Code die Racebedingungen erfüllen.

Daraus ergibt sich das folgende Prinzip. Damit eine Sperre für einen Speicherbereich wechselseitigen Ausschluss ermöglicht, dürfen keine Schreibvorgänge in den betreffenden Speicher erfolgen, ohne dass dieselbe Sperre aktiviert wird. In einem einwandfrei konzipierten Programm ist jeder Sperre ein Speicherbereich zugeordnet, für den sie den wechselseitigen Ausschluss bewirkt. Leider gibt es keine offensichtlichen Codekonstrukte, die diesen Zusammenhang klar herausstellen würden, und doch sind diese Informationen für jeden, der das Multithread-Verhalten eines Programms konzipiert, von absolut entscheidender Bedeutung.

Daher sollte jeder Sperre eine Spezifikation zugeordnet sein, die den genauen Speicherbereich (Satz von Datenstrukturen) dokumentiert, für den die Sperre einen wechselseitigen Ausschluss bewirkt. Im totalRequests-Beispiel schützt totalRequestsLock ausschließlich die totalRequests-Variable. In realen Programmen werden durch Sperren eher größere Bereiche geschützt, beispielsweise eine ganze Datenstruktur, mehrere zusammengehörige Datenstrukturen oder der gesamte Speicher, der über eine Datenstruktur erreichbar ist. Gelegentlich schützt eine Sperre nur einen Teil einer Datenstruktur (z. B. die Hashbucketkette einer Hashtabelle). Um was für einen Bereich es sich auch handeln mag, es kommt darauf an, dass der Programmierer diesen dokumentiert. Wenn die Spezifikation niedergelegt ist, kann methodisch validiert werden, ob die Sperre vor dem Aktualisieren des zugeordneten Speichers aktiviert wird. Die meisten Races werden dadurch verursacht, dass vor dem Zugriff auf den entsprechenden Speicher nicht stets die richtige Sperre aktiviert wird. Der zeitliche Aufwand für diese Prüfung lohnt sich daher.

Wenn für jede Sperre genau festgelegt ist, welcher Speicher durch sie geschützt wird, überprüfen Sie, ob sich die durch verschiedene Sperren geschützten Bereiche überschneiden. Überschneidungen sind zwar nicht streng verboten, sollten jedoch vermieden werden, da Überschneidungen von Speicherbereichen, die unterschiedlichen Sperren zugeordnet sind, nicht sinnvoll sind. Überlegen Sie, was geschieht, wenn der Speicherbereich aktualisiert werden muss, der beiden Sperren gemeinsam ist. Welche Aktualisierung wird dann übernommen? Es gibt dann u. a. folgende Möglichkeiten:

Willkürlich nur eine der Sperren aktivieren: Diese Konvention funktioniert nicht, da dann kein wechselseitiger Ausschluss mehr erfolgt. Es können dann zwei unterschiedliche Aktualisierungsorte unterschiedliche Sperren aktivieren und dabei gleichzeitig denselben Speicherbereich aktualisieren.

Stets beide Sperren aktivieren: Damit ist zwar für wechselseitigen Ausschluss gesorgt, dieses Vorgehen ist jedoch doppelt so aufwändig und bietet keinen Vorteil gegenüber nur einer Sperre für den Bereich.

Stets eine der Sperren aktivieren: Das läuft darauf hinaus, dass der jeweilige Bereich nur durch eine Sperre geschützt wird.

Wie viele Sperren?

Das Beispiel zur Darstellung des nächsten Punkts ist etwas komplizierter. Angenommen, anstelle nur eines totalRequests-Zählers gibt es zwei verschiedene Zähler für Anforderungen mit hoher und niedriger Priorität. Der Wert für totalRequests wird nicht direkt gespeichert, sondern folgendermaßen berechnet:

totalRequests = highPriRequests + lowPriRequests;

Das Programm benötigt die Invariante, dass die Summe der beiden globalen Variablen angibt, wie oft alle Threads eine Anforderung verarbeitet haben. Im Unterschied zum vorherigen Beispiel bezieht sich diese Invariante auf zwei Speicherbereiche. Damit wird sofort die Frage aufgeworfen, ob eine oder zwei Sperren benötigt werden. Die Antwort darauf hängt von Ihren Entwurfszielen ab.

Der Hauptvorteil von zwei Sperren, einer für highPriRequests und einer anderen für lowPriRequests, besteht darin, dass mehr Parallelität möglich ist. Wenn ein Thread versucht, highPriRequests zu aktualisieren, und ein anderer Thread versucht, lowPriRequests zu aktualisieren, jedoch nur eine Sperre vorhanden ist, müsste ein Thread auf den anderen warten. Bei zwei Sperren könnte jeder Thread ohne Konflikt fortgesetzt werden. Im Beispiel ist diese Verbesserung der Parallelität trivial, da eine einzelne Sperre relativ selten aktiviert und zudem nicht für eine längere Zeit gehalten wird. Stellen Sie sich jedoch vor, dass die Sperre eine Tabelle schützt, die bei der Verarbeitung von Anforderungen häufig verwendet wird. In diesem Fall lohnt es sich eventuell, nur Teile der Tabelle zu sperren (z. B. die Hashbucketeinträge), so dass mehrere Threads gleichzeitig auf die Tabelle zugreifen können.

Der Hauptnachteil von zwei Sperren ist die damit einhergehende Komplexität. Da das Programm eindeutig mehr Elemente enthält, haben Programmierer mehr Möglichkeiten zu Fehlern. Die Komplexität nimmt mit der Anzahl der Sperren im System schnell zu. Daher empfiehlt es sich, große Speicherbereiche mit wenigen Sperren zu schützen und sie nur zu teilen, wenn sich Sperrenkonflikte als Leistungsengpass erweisen.

Im Extremfall könnte ein Programm nur eine Sperre haben, die den gesamten Speicher schützt, auf den mehrere Threads zugreifen können. Ein solcher Entwurf würde sich für das Beispiel mit der Anforderungsverarbeitung sehr gut eignen, wenn ein Thread eine Anforderung verarbeiten kann, ohne auf gemeinsame Daten zugreifen zu müssen. Muss ein Thread zur Verarbeitung einer Anforderung den gemeinsamen Speicher häufig aktualisieren, wird eine einzelne Sperre zu einem Engpass. In diesem Fall muss der einzelne große, durch eine einzige Sperre geschützte Speicherbereich in mehrere sich nicht überschneidende Bereiche aufgeteilt werden, die jeweils durch eine eigene Sperre geschützt werden.

Anwenden von Sperren auf Lesevorgänge

Bisher habe ich die Konvention dargestellt, dass eine Sperre stets vor dem Schreiben eines Speicherbereichs aktiviert werden sollte. Ich habe jedoch nicht erläutert, was beim Lesen des Speichers geschehen soll. Dieser Fall ist ein wenig komplizierter, da hier die Erwartungen des Programmierers eine Rolle spielen. Betrachten Sie das Beispiel erneut. Angenommen, Sie haben sich entschieden, die Werte für highPriRequests und lowPriRequests zu lesen, um den Wert von totalRequests zu berechnen.

totalRequests = highPriRequests + lowPriRequests;

In diesem Fall wird erwartet, dass sich durch die Addition der Werte in diesen beiden Speicherbereichen die richtige Gesamtzahl der Anforderungen ergibt. Dies trifft nur dann zu, wenn sich die Werte während der Ausführung dieser Berechnung nicht ändern. Wenn jeder Zähler über eine eigene Sperre verfügt, müssen beide Sperren aktiviert werden. Erst dann kann die Summe berechnet werden.

Dagegen muss der Code, durch den highPriRequests inkrementiert wird, nur eine Sperre aktivieren. Grund dafür ist, dass die einzige vom Aktualisierungscode vorausgesetzte Invariante darin besteht, dass highPriRequests den exakten Wert enthält. Der Wert lowPriRequests ist überhaupt nicht beteiligt. Wenn Code eine Programminvariante benötigt, müssen im Allgemeinen alle Sperren aktiviert werden, die mit einem an der Invariante beteiligten Speicherbereich verknüpft sind.

Dieser Punkt lässt sich durch die in Abbildung 5 dargestellte Analogie veranschaulichen. Stellen Sie sich den Speicher des Computers als einen Spielautomaten vor, einen einarmigen Banditen mit Tausenden von Fenstern, eines für jeden Speicherbereich. Das Starten des Programms entspricht dem Ziehen des Hebels. Die Speicherbereiche beginnen sich zu drehen, wenn die Werte im Speicher durch andere Threads geändert werden. Wenn ein Thread eine Sperre aktiviert, drehen sich die der Sperre zugeordneten Bereiche nicht mehr, da der Code der Konvention folgt, dass eine Sperre aktiviert werden muss, bevor eine Aktualisierung möglich ist. Der Thread kann diesen Vorgang wiederholen, so dass weitere Sperren aktiviert und so mehr Speicherbereiche blockiert werden, bis alle vom Thread benötigten Speicherbereiche unbeweglich sind. Der Thread ist nun in der Lage, den Vorgang ohne Störung durch andere Threads auszuführen.

Fünf Schritte zum Austauschen von Werten, die durch Sperren geschützt sind
Abbildung 5: Fünf Schritte zum Austauschen von Werten, die durch Sperren geschützt sind

Diese Analogie eignet sich gut dazu, Programmierer von der Meinung abzubringen, dass sich nur etwas ändert, wenn es explizit geändert wird, und sie davon zu überzeugen, dass sich alles ändert, wenn keine Sperren verwendet werden, die dies verhindern. Diese neue Denkweise zu übernehmen, ist der wichtigste Rat beim Erstellen von Multithread-Anwendungen.

Welche Speicherbereiche müssen durch Sperren geschützt werden?

Sie haben gesehen, wie Programminvarianten durch Sperren geschützt werden. Ich habe jedoch noch nicht genau dargelegt, welche Speicherbereiche einen solchen Schutz benötigen. Einfach (und richtig) wäre es zu sagen, dass der gesamte Speicher durch eine Sperre geschützt werden muss. Für die meisten Anwendungen wäre dies jedoch des Guten zuviel.

Speicher kann für die Multithread-Verwendung auf eine der folgenden Arten geschützt werden.

Erstens: Speicher, auf den nur ein Thread zugreift, ist sicher, da andere Threads davon nicht betroffen sind. Hierzu gehören die meisten lokalen Variablen und der gesamte für den Heap reservierte Speicher, bevor dieser veröffentlicht wird (so dass andere Threads darauf zugreifen können). Sobald der Speicher veröffentlicht ist, fällt er jedoch aus dieser Kategorie heraus, und es muss ein anderes Verfahren verwendet werden.

Zweitens: Speicher, auf den nach der Veröffentlichung nur lesend zugegriffen wird, benötigt keine Sperre, da alle damit verknüpften Invarianten für die restliche Dauer des Programms erhalten bleiben (da sich der Wert nicht ändert).

Drittens: Für Speicher, der aktiv durch mehrere Threads aktualisiert wird, werden meistens Sperren verwendet, um sicherzustellen, dass nur ein Thread auf den Speicher zugreifen kann, während eine Programminvariante zerstört ist.

In bestimmten Spezialfällen schließlich, in denen die Programminvariante relativ schwach ist, können Aktualisierungen ohne Sperren ausgeführt werden. In der Regel werden dazu spezielle Vergleichs- und Austauschanweisungen verwendet. Diese Verfahren können am besten als spezielle "leichte" Implementierungen von Sperren aufgefasst werden.

Der gesamte in einem Programm verwendete Speicher dürfte einem dieser vier Fälle zuzuordnen sein. Der letzte Fall ist wesentlich komplizierter und fehleranfälliger und sollte daher nur verwendet werden, wenn der damit einhergehende zusätzliche Aufwand und das Risiko durch die Leistungsanforderungen gerechtfertigt werden (diesem Fall werde ich in einem demnächst erscheinenden Artikel nachgehen). Wenn Sie diesen Fall vorerst ignorieren, gilt als allgemeine Regel, dass der gesamte Programmspeicher einem der folgenden drei Bereiche angehört: exklusiv für den Thread, nur lesender Zugriff oder durch Sperren geschützt.

Methodisches Sperren

In der Praxis weisen die meisten nicht trivialen Multithread-Programme zahlreiche Races auf. Das Problem rührt im Wesentlichen daher, dass die Programmierer nicht genau wissen, wann Sperren benötigt werden. Ich hoffe, ich konnte diesen Punkt klären. Dieses Wissen allein reicht jedoch nicht aus. Es ist schrecklich einfach, Fehler zu begehen, da schon durch eine einzige fehlende Sperre Races erzeugt werden. Sie müssen streng methodisch vorgehen, um einfache, aber häufige Fehler zu vermeiden. Doch auch die besten aktuellen Verfahren erfordern große Sorgfalt, um sie richtig anzuwenden.

Eines der einfachsten und nützlichsten Verfahren für das methodische Sperren ist ein Monitor. Die Grundidee besteht darin, die Datenabstraktion zu übernehmen, die bereits in einem objektorientierten Entwurf enthalten ist. Betrachten Sie das Beispiel einer Hashtabelle. In einer sinnvoll entwickelten Klasse gilt bereits, dass Clients nur über den Aufruf von Instanzmethoden der Klasse auf deren internen Zustand zugreifen. Wird beim Aktivieren einer Instanzmethode eine Sperre angewendet und die Sperre beim Deaktivieren dieser Methode aufgehoben, lässt sich auf systematische Weise sicherstellen, dass alle Zugriffe auf interne Daten (Instanzfelder) nur erfolgen, während die Sperre aktiviert ist (siehe Abbildung 6). Klassen, die diesem Protokoll folgen, werden als Monitore bezeichnet.

Verwenden einer Monitor-Klasse
Abbildung 6: Verwenden einer Monitor-Klasse

Es ist kein Zufall, dass eine der Sperren im .NET Framework den Namen System.Threading.Monitor hat. Dieser Typ wurde speziell für die Unterstützung des Monitorkonzepts erstellt. System.Object wird von .NET-Sperren deshalb verwendet, um die Erstellung von Monitoren zu vereinfachen. In der Tat verfügt jedes Objekt über eine integrierte Sperre, mit der sich seine Instanzdaten schützen lassen. Durch Einbetten des Hauptteils jeder Instanzmethode in eine lock(this)-Anweisung kann ein Monitor gebildet werden. Es gibt sogar ein spezielles Attribut, [MethodImpl(MethodImplAttributes.Synchronized)], das in Instanzmethoden eingefügt werden kann, um die lock(this)-Anweisung automatisch einzufügen. Darüber hinaus sind .NET-Sperren wiedereintrittsfähig. Das heißt, dass der Thread, der eine Sperre aktiviert hat, diese Sperre erneut aktivieren kann, ohne blockiert zu werden. So können Methoden andere Methoden derselben Klasse aufrufen, ohne den normalerweise auftretenden Deadlock zu verursachen.

Zwar sind Monitore nützlich (und mit .NET einfach zu programmieren), sie sind jedoch bei weitem kein Patentrezept für die Anwendung von Sperren. Werden sie wahllos verwendet, werden unter Umständen zu wenige oder zu viele Sperren erstellt. Stellen Sie sich eine Anwendung vor, die die in Abbildung 6 dargestellte Hashtabelle verwendet, um eine höhere Operation namens Debit zu implementieren, mit der Geld von einem Konto auf ein anderes überwiesen wird. Die Debit-Methode verwendet die Find-Methode, um die beiden Konten aus Hashtable abzurufen, und die Update-Methode für die eigentliche Ausführung der Überweisung. Da Hashtable ein Monitor ist, sind die Aufrufe von Find und Update garantiert atomar. Leider benötigt die Debit-Methode mehr als diese Garantie des atomaren Charakters. Wenn ein anderer Thread eines der Konten zwischen den beiden Update-Aufrufen von Debit aktualisiert, kann Debit zu einem falschen Ergebnis führen. Der Monitor hat Hashtable innerhalb einzelner Aufrufe zwar ausgezeichnet geschützt, eine notwendige Invariante über mehrere Aufrufe hinweg wurde jedoch nicht gewährleistet, da zu wenig Sperren gesetzt wurden.

Wird das Race in der Debit-Methode mit einem Monitor beseitigt, werden unter Umständen zu viele Sperren gesetzt. Benötigt wird eine Sperre, die den gesamten von der Debit-Methode verwendeten Speicher schützt und für die Dauer der Methode aktiviert bleibt. Wenn Sie dazu einen Monitor verwenden, würde dieser der in Abbildung 7 dargestellten Accounts-Klasse entsprechen. Jede höhere Operation wie Debit oder Credit wendet die Sperre auf Accounts an, bevor die entsprechende Operation ausgeführt wird, und sorgt so für den erforderlichen wechselseitigen Ausschluss. Durch den Accounts-Monitor wird zwar das Race beseitigt, allerdings wird jetzt die Bedeutung der Sperre in Hashtable in Frage gestellt. Wenn bei allen Zugriffen auf Accounts (und damit auf Hashtable) die Accounts-Sperre aktiviert wird, ist der wechselseitige Ausschluss für Zugriffe auf Hashtable (die Teil von Accounts ist) bereits sichergestellt. Ist dies der Fall, so ist der Aufwand zum Erstellen einer Sperre für Hashtable überflüssig. Es wurden zu viele Sperren gesetzt.

Monitore werden nur auf der obersten Ebene benötigt
Abbildung 7: Monitore werden nur auf der obersten Ebene benötigt

Eine weitere wichtige Schwäche des Monitorkonzepts besteht darin, dass es keinen Schutz bietet, wenn die Klasse aktualisierbare Zeiger auf ihre Daten ausgibt. Methoden wie Find in Hashtable geben z. B. häufig ein Objekt zurück, das vom Aufrufer anschließend aktualisiert werden kann. Da diese Aktualisierungen außerhalb eines Aufrufs von Hashtable erfolgen können, sind sie nicht durch eine Sperre geschützt, und der Schutz, den der Monitor eigentlich gewähren sollte, wird dadurch zerstört. Außerdem eignen sich Monitore einfach nicht für kompliziertere Situationen, in denen mehrere Sperren aktiviert werden müssen.

Monitore sind zwar nützlich, dienen jedoch lediglich dazu, einen gut durchdachten Entwurf von Sperren zu implementieren. Manchmal fällt der Speicher, der durch eine Sperre geschützt wird, mit einer Datenabstraktion zusammen, so dass sich ein Monitor perfekt zur Implementierung der Sperren eignet. In anderen Fällen jedoch schützt eine Sperre viele Datenstrukturen oder nur Teile einer Datenstruktur. In diesen Fällen ist ein Monitor ungeeignet. Es führt kein Weg um die Schwierigkeit herum, genau zu definieren, welche Sperren ein System benötigt und welche Speicherbereiche von den einzelnen Sperren geschützt werden. Lassen Sie uns nun verschiedene Richtlinien untersuchen, die Sie bei diesem Entwurf unterstützen können.

Wieder verwendbarer Code (wie Containerklassen) sollte grundsätzlich keine Sperren enthalten, da er sich nur selbst schützen kann. Und wahrscheinlich benötigt jeglicher Code, der solchen Code verwendet, auf jeden Fall eine stärkere Sperre. Diese Regel gilt nur dann nicht, wenn es darauf ankommt, dass der Code auch bei Programmfehlern auf höherer Ebene funktioniert. Der globale Speicherheap und sicherheitsrelevanter Code sind Beispiele für Ausnahmefälle.

Die Verwendung weniger Sperren, die große Speicherbereiche schützen, ist nicht so fehleranfällig und effizienter. Eine einzelne Sperre, die viele Datenstrukturen schützt, ist ein guter Entwurf, wenn sie das gewünschte Ausmaß an Parallelität zulässt. Wenn die von den einzelnen Threads ausgeführten Vorgänge nur wenige Aktualisierungen des gemeinsamen Speichers erfordern, würde ich in Erwägung ziehen, dieses Prinzip auf die Spitze zu treiben und eine Sperre zu aktivieren, die den gesamten gemeinsamen Speicher schützt. Dadurch wird das Programm fast so einfach wie ein sequenzielles Programm. Diese Vorgehensweise eignet sich gut für Anwendungen, deren Arbeitsthreads nur wenig interagieren.

Wenn Threads gemeinsame Strukturen häufig lesen, jedoch selten schreiben, kann mit Leser-Schreiber-Sperren wie System.Threading.ReaderWriterLock die Anzahl der Sperren im System niedrig gehalten werden. Dieser Sperrentyp verfügt über eine Methode zum Aktivieren eines Lesevorgangs und eine zum Aktivieren eines Schreibvorgangs. Die Sperre lässt die gleichzeitige Aktivierung durch mehrere Leser zu. Schreiber hingegen erhalten exklusiven Zugriff. Da die Leser sich nun nicht gegenseitig blockieren, kann das System vereinfacht (weniger Sperren) und dennoch die notwendige Parallelität erreicht werden.

Leider ist es selbst bei Beachtung dieser Richtlinien grundsätzlich schwieriger, Systeme mit hoher Parallelität zu entwerfen als sequenzielle Systeme zu schreiben. Die Verwendung von Sperren kann häufig mit der normalen objektorientierten Programmabstraktion in Konflikt stehen, da die Verwendung von Sperren wirklich eine andere unabhängige Dimension des Programms darstellt, die ihren eigenen Entwurfskriterien folgt (weitere Dimensionen sind z. B. die Verwaltung von Lebenszyklen, Transaktionsverhalten, Echtzeiteinschränkungen und das Verhalten bei der Ausnahmebehandlung). Gelegentlich decken sich die Anforderungen für die Verwendung von Sperren mit den Anforderungen der Datenabstraktion, beispielsweise wenn beide zur Steuerung des Zugriffs auf Instanzdaten verwendet werden. Andererseits stehen sie jedoch auch miteinander in Konflikt. (Monitore können nicht sinnvoll geschachtelt werden, und Zeiger können die Wirksamkeit von Monitoren aufheben.)

Dieser Konflikt lässt sich nicht zufrieden stellend lösen. Am Ende sind Multithread-Programme eben komplizierter. Der Trick besteht darin, die Komplexität zu steuern. Sie haben bereits eine Strategie kennen gelernt: Versuchen Sie, auf der obersten Ebene der Datenabstraktionshierarchie nur wenige Sperren zu verwenden. Selbst diese Strategie kann mit der Modularität in Konflikt stehen, da viele Datenstrukturen wahrscheinlich durch eine Sperre geschützt werden. Dies bedeutet, dass es keine offensichtliche Datenstruktur gibt, an die die Sperre gebunden werden kann. Normalerweise muss die Sperre eine globale Variable (bei Lese-/Schreibdaten niemals wirklich sinnvoll) oder Teil der globalsten beteiligten Datenstruktur sein. Im letztgenannten Fall muss es möglich sein, diese Struktur von jeder anderen Struktur aus zu erreichen, die die Sperre benötigt. Dies ist gelegentlich mühselig, da eventuell einigen Methoden zusätzliche Parameter hinzugefügt werden müssen. Zudem gerät der Entwurf dadurch ein wenig in Unordnung. Dennoch ist dies den Alternativen vorzuziehen.

Wenn sich ein Entwurf als derart komplex erweist, ist es richtig, diese Komplexität explizit zu machen und sie nicht zu ignorieren. Wenn eine bestimmte Methode selbst keine Sperren aktiviert, sondern erwartet, dass der Aufrufer für den wechselseitigen Ausschluss sorgt, dann ist diese Anforderung eine Voraussetzung für den Aufruf dieser Methode und sollte in deren Schnittstellenvertrag enthalten sein. Wenn dagegen eine Datenabstraktion eine Sperre aktiviert oder (virtuelle) Clientmethoden aufruft, während eine Sperre aktiviert ist, muss dies ebenfalls Bestandteil des Schnittstellenvertrags sein. Nur wenn Sie diese Details an den Schnittstellengrenzen explizit machen, können Sie lokal gute Entscheidungen bezüglich des Codes treffen. In einem guten Entwurf sind die meisten dieser Verträge trivial. Der Aufgerufene erwartet, dass der Aufrufer für die gesamte beteiligte Datenstruktur den Ausschluss übernimmt, sodass es nicht schwierig ist, dies anzugeben.

Im Idealfall würde die Komplexität des Parallelitätsentwurfs in einer Klassenbibliothek verborgen. Leider können Entwickler von Klassenbibliotheken auch nur wenig unternehmen, um die Eignung einer Bibliothek für das Multithreading zu verbessern. Wie das Hashtable-Beispiel zeigt, ist das Sperren nur einer Datenstruktur selten hilfreich. Lediglich beim Entwerfen der Threadingstruktur des Programms können Sperren sinnvoll hinzugefügt werden. In der Regel ist dies daher die Aufgabe des Anwendungsentwickers. Nur holistische Frameworks wie ASP.NET, die die Threadingstruktur definieren, auf die der Endbenutzercode zugreift, sind in der Lage, Benutzer von der Last einer sorgfältigen Entwicklung und Analyse von Sperren zu befreien.

Deadlock

Deadlocks sind ein weiterer Grund dafür, eine hohe Anzahl von Sperren im System zu vermeiden. Sobald ein Programm mehrere Sperren aufweist, ist ein Deadlock möglich. Wenn ein Thread z. B. versucht, Sperre A und danach Sperre B zu aktivieren, während ein anderer Thread gleichzeitig versucht, Sperre B und danach Sperre A zu aktivieren, können sie einen Deadlock erzeugen, falls jeder Thread die Sperre aktiviert, die Eigentum des anderen Threads ist, bevor er versucht, die zweite Sperre zu aktivieren.

In der Praxis gibt es grundsätzlich zwei Möglichkeiten, Deadlocks zu vermeiden. Die erste (und beste) Möglichkeit besteht darin, so wenige Sperren im System zu verwenden, dass es niemals notwendig ist, jeweils mehr als eine Sperre zu aktivieren. Wenn dies nicht möglich ist, können Deadlocks auch durch das Festlegen der Reihenfolge verhindert werden, in der die Sperren aktiviert werden. Deadlocks können durch eine Ringkette von Threads hervorgerufen werden, die bewirkt, dass jeder Thread in der Kette auf eine Sperre wartet, die bereits vom folgenden Thread aufgerufen wurde. Um dies zu verhindern, wird jeder Sperre im System eine "Ebene" zugewiesen. Das Programm wird so konzipiert, dass Threads Sperren nur in streng absteigender Ebenenreihenfolge aktivieren. Bei einem solchen Protokoll sind Ringe aus Sperren und somit Deadlocks nicht möglich. Wenn diese Strategie nicht funktioniert (da keine Ebenen vorhanden sind), ist das Sperrverhalten des Programms wahrscheinlich so eingabeabhängig, dass das Entstehen von Deadlocks unmöglich in jedem Fall ausgeschlossen werden kann. Normalerweise erfolgt bei Code dieser Art bei einem Timeout oder einem anderen Deadlock-Erkennungsschema ein Fallback.

Deadlocks sind ein weiterer Grund dafür, die Anzahl der Sperren im System gering zu halten. Wenn dies nicht möglich ist, muss mithilfe einer Analyse festgestellt werden, warum mehrere Sperren gleichzeitig angewendet werden müssen. Denken Sie daran: Das Aktivieren mehrerer Sperren ist nur notwendig, wenn der Code exklusiven Zugriff auf Speicher benötigt, der durch verschiedene Sperren geschützt wird. Diese Analyse ergibt normalerweise eine simple Sperrenreihenfolge, mit der sich Deadlocks vermeiden lassen, oder zeigt, dass sich Deadlocks nicht vollständig verhindern lassen.

Leistungseinbußen durch Sperren

Ein weiterer Grund zur Vermeidung einer großen Zahl von Sperren in einem System sind die Leistungseinbußen beim Aktivieren und Deaktivieren einer Sperre. Die einfachsten Sperren überprüfen mithilfe einer speziellen Vergleichs-/Austauschanweisung, ob die Sperre aktiviert wurde. Ist dies nicht der Fall, wird die Sperre in einer einzelnen atomaren Aktion aktiviert. Leider ist diese spezielle Anweisung relativ aufwändig (sie dauert in der Regel zehn bis hundert Mal länger als eine normale Anweisung). Es gibt im Wesentlichen zwei Gründe für diesen Aufwand, und beide haben mit den Besonderheiten eines echten Multiprozessorsystems zu tun.

Der erste Grund ist, dass die Vergleichs-/Austauschanweisung sicherstellen muss, dass kein anderer Prozessor den gleichen Vorgang auszuführen versucht. Dies bedeutet im Grunde genommen, dass ein Prozessor mit allen übrigen Prozessoren im System koordiniert werden muss. Dieser Vorgang nimmt relativ viel Zeit in Anspruch und macht die Untergrenze der Leistungseinbußen für eine Sperre (Dutzende von Zyklen) aus. Der andere Grund für Leistungseinbußen ist die Auswirkung prozessübergreifender Kommunikation auf das Speichersystem. Nachdem eine Sperre aktiviert wurde, greift das Programm höchstwahrscheinlich auf Speicher zu, der kurz vorher möglicherweise von einem anderen Thread geändert wurde. Wenn dieser Thread auf einem anderen Prozessor ausgeführt wurde, muss sichergestellt werden, dass alle ausstehenden Schreibvorgänge von allen anderen Prozessoren umgesetzt und die Zwischenspeicher gelöscht wurden, so dass die Aktualisierungen auch wirklich für den aktuellen Thread verfügbar sind. Die damit verbundenen Leistungseinbußen hängen größtenteils von der Funktionsweise des Speichersystems ab sowie davon, wie viele Schreibvorgänge umgesetzt werden müssen. Im schlimmsten Fall (möglicherweise Hunderte von Zyklen) können diese Leistungseinbußen sehr hoch sein.

Die Leistungseinbußen durch eine Sperre können also sehr groß sein. Wenn eine häufig aufgerufene Methode eine Sperre aktivieren muss und nur etwa einhundert Anweisungen ausführt, stellt der Aufwand für die Sperre wahrscheinlich ein Problem dar. Das Programm muss daraufhin im Allgemeinen neu entworfen werden, damit die Sperre für eine größere Arbeitseinheit aktiviert werden kann.

Das Aktivieren und Deaktivieren von Sperren erfordert nicht nur viel Aufwand, vielmehr werden Sperren mit zunehmender Anzahl von Systemprozessoren auch zum Haupthindernis für den effizienten Einsatz aller Prozessoren. Wenn das Programm zu wenige Sperren enthält, ist es nicht möglich, alle Prozessoren auszulasten, da sie auf Speicher warten, der von einem anderen Prozessor gesperrt wurde. Enthält das Programm andererseits zu viele Sperren, kann es leicht zu einer "heißen" Sperre kommen, die von vielen Prozessoren häufig aktiviert und deaktiviert wird. Dadurch wird der Aufwand zum Umsetzen von Speicherschreibvorgängen sehr hoch, so dass der Durchsatz nicht linear mit der Anzahl der Prozessoren steigt. Gut skalierbar ist nur ein Entwurf, in dem Arbeitsthreads einen Großteil der Arbeit übernehmen, ohne mit gemeinsam verwendeten Daten zu interagieren.

Aufgrund von Leistungsproblemen möchten Sie Sperren möglicherweise ganz vermeiden. Dies ist unter ganz bestimmten Umständen zwar möglich, die richtige Ausführung ist jedoch noch komplizierter als die richtige Umsetzung des gegenseitigen Ausschlusses mithilfe von Sperren. Dieser Schritt sollte nur ein letzter Ausweg sein und nur in Angriff genommen werden, wenn Sie die damit verbundenen Probleme kennen. Diesem Thema werde ich demnächst einen ganzen Artikel widmen.

Kurze Anmerkung zur Synchronisierung

Sperren sind zwar ein Mittel, Threads voneinander zu trennen, sie ermöglichen jedoch keine Kooperation (Synchronisierung) der Threads untereinander. Ich werde kurz die Prinzipien für die Synchronisierung von Threads ohne Erzeugung von Races umreißen. Sie werden feststellen, dass sie sich nicht sehr von den Prinzipien für den sachgemäßen Einsatz von Sperren unterscheiden.

Im .NET Framework ist die Synchronisierungsfunktion in der ManualResetEvent-Klasse und der AutoResetEvent-Klasse implementiert. Diese Klassen enthalten die Methoden Set, Reset und WaitOne. Die WaitOne-Methode blockiert einen Thread, solange das Ereignis sich im Zurücksetzungszustand befindet. Wird die Set-Methode von einem anderen Thread aufgerufen, ermöglicht AutoResetEvents die Aufhebung der Blockierung eines Threads, von dem WaitOne aufgerufen wurde, während mit ManualResetEvents die Blockierung aller wartenden Threads aufgehoben wird.

Im Allgemeinen dienen Ereignisse zur Signalisierung, dass eine komplexere Programmeigenschaft einen bestimmten Wert hat. Ein Programm verfügt z. B. möglicherweise über eine Warteschlange von Arbeiten für einen Thread. Mit einem Ereignis wird dem Thread signalisiert, dass die Warteschlange nicht leer ist. Damit wird also die Programminvariante eingeführt, dass das Ereignis ausgelöst werden soll, wenn und nur wenn die Warteschlange nicht leer ist. Die Regeln zum ordnungsgemäßen Anwenden von Sperren verlangen Folgendes: Wenn der Code eine Invariante benötigt, müssen Sperren vorhanden sein, die exklusiven Zugriff auf den gesamten der Invariante zugeordneten Speicher gewähren. Die Anwendung dieses Prinzips in einer Warteschlange legt nahe, dass der gesamte Zugriff auf das Ereignis und die Warteschlange erst nach dem Aktivieren einer gemeinsamen Sperre erfolgen sollte.

Leider kann es bei einem solchen Entwurf zu einem Deadlock kommen. Nehmen Sie z. B. folgendes Szenario: Thread A aktiviert die Sperre und muss darauf warten, dass die Warteschlange gefüllt wird (während er Eigentümer der Warteschlangensperre ist). Thread B, der versucht, der von Thread A benötigten Warteschlange einen Eintrag hinzuzufügen, wird versuchen, die Sperre der Warteschlange zu aktivieren, bevor er die Warteschlange ändert, und blockiert damit Thread A. Das Ergebnis: ein Deadlock!

Es ist grundsätzlich nicht sinnvoll, beim Warten auf Ereignisse Sperren zu aktivieren. Warum auch sollten alle übrigen Threads von einer Datenstruktur ausgesperrt werden, wenn ein Thread auf einen anderen Vorgang wartet? Auf diese Weise werden Deadlocks geradezu heraufbeschworen. Ein übliches Verfahren ist es, die Sperre freizugeben und dann auf das Ereignis zu warten. Damit ist es jedoch möglich, dass Ereignis und Warteschlange nicht mehr synchron sind. Wir haben die Invariante zerstört, dass das Ereignis genau angibt, wann die Warteschlange nicht leer ist. Eine typische Lösung ist in diesem Fall die Abschwächung der Invariante zu "wenn das Ereignis zurückgesetzt wird, ist die Warteschlange leer". Diese Invariante ist ausreichend stark, so dass es immer noch sicher ist, auf das Ereignis zu warten, ohne unbegrenzt warten zu müssen. Diese gelockerte Invariante bedeutet Folgendes: Wenn ein Thread von WaitOne zurückkehrt, kann er nicht davon ausgehen, dass die Warteschlange ein Element enthält. Der erwachende Thread muss die Sperre der Warteschlange aktivieren und überprüfen, ob die Warteschlange ein Element enthält. Falls dies nicht der Fall ist (da der Eintrag z. B. von einem anderen Thread entfernt wurde), muss er erneut warten. Wenn es auf Fairness zwischen Threads ankommt, gibt es bei dieser Lösung ein Problem. Für die meisten Zwecke eignet sie sich jedoch gut.

Schlussbemerkung

Die Grundprinzipien guten Programmierens für sequenzielle Programme und Multithread-Programme unterscheiden sich nicht wesentlich. In beiden Fällen muss die gesamte Codebasis die Invarianten, die an anderer Stelle im Programm benötigt werden, methodisch schützen. Wie aus Tabelle 2 hervorgeht, besteht der Unterschied darin, dass der Schutz von Programminvarianten bei Multithreading komplizierter ist. Das Erstellen eines einwandfreien Multithread-Programms erfordert daher ein wesentlich höheres Maß an Disziplin. Einerseits muss der Entwickler durch Mittel wie Monitore sicherstellen, dass alle von Threads gemeinsam verwendeten Lese-/Schreibdaten durch Sperren geschützt sind. Andererseits muss er sorgfältig planen, welcher Speicher durch welche Sperre geschützt wird. Die zusätzliche Komplexität, die in einem Programm durch Sperren unvermeidlich entsteht, muss in Grenzen gehalten werden. Wie bei sequenziellen Programmen sind einfache Entwürfe normalerweise die besten. Bei Multithread-Programmen bedeutet dies, eine möglichst geringe Anzahl von Sperren zu verwenden, mit denen noch die benötigte Parallelität erzielt wird. Wenn Sie die Sperren einfach konzipieren und Ihren Entwurf methodisch umsetzen, ist es möglich, Multithread-Programme ohne Races zu schreiben.

Vance Morrison ist der Compilerarchitekt für die .NET-Laufzeit bei Microsoft. Dort war er von Anfang an am Entwurf der .NET-Laufzeit beteiligt. Er steuerte den Entwurf für die .NET Intermediate Language (IL) und war Leiter des JIT-Compilerteams (Just In Time).

Aus der Ausgabe August 2005 des MSDN Magazine.