|
|||||||||||||||||||||||||||||||||
HOME | COURSES | TALKS | ARTICLES | GENERICS | LAMBDAS | IOSTREAMS | ABOUT | CONTACT | | | | |||||||||||||||||||||||||||||||||
|
Java Multithread Support - Nested Monitor Problem
|
||||||||||||||||||||||||||||||||
In unserem letzten Artikel /
KRE3
/ haben wir uns
angesehen, wie ein Thread durch Aufruf der Methode Object.wait() auf das
Eintreten einer logischen Bedingung warten kann, die ihm ein anderer Thread
durch Aufruf der Methode Object.notify() oder Object.notifyAll() signalisiert.
Dabei haben wir für das Warten und Signalisieren mehrerer logischer
Bedingungen immer nur ein einziges Bedingungsobjekt verwendet. In diesem
Artikel wollen wir uns ansehen, warum man das so macht bzw. warum die Verwendung
mehrerer Bedingungsobjekte bei der Benutzung von wait() und notify() (bzw
notifyAll()) in der Regel zu Problemen (dem sogenannten Nested-Monitor-Problem)
führt. Am Ende des Artikel schauen wir uns dann noch an, wie die Neuerungen
im JDK 1.5 dazu führen, dass das bisherige Standardvorgehen bei der
Benutzung von Bedingungen in Java sich geändert hat.
Rückblick: der BlockingIntStackIn Multithread-Umgebungen können sich Threads über das Eintreten von logischen Bedingungen verständigen. Dabei warten ein oder mehrere Threads durch Aufruf der Methode Object.wait() auf das Eintreten einer logischen Bedingung. Das Eintreten der Bedingung wird von einem oder mehreren anderen Threads erkannt und den wartenden Threads mit Hilfe von Object.notify() oder Object.notifyAll() signalisiert. Als Beispiel für die Nutzung dieser Technik haben wir einen BlockingIntStack implementiert. Dieser Stack erlaubt es einem Thread, der mit pop() einen int-Wert aus einem leeren Stack holen will, zu warten, bis ein anderer Thread, der mit push() einen int-Wert geschrieben hat, ihm signalisiert, dass nun ein Element im Stack vorhanden ist. Zur Ergänzung, hier noch einmal der Sourcecode:public class BlockingIntStack {Wie man an der Implementierung sehen kann, hat eine Instanz eines BlockingIntStack eine feste Größe und wächst nicht dynamisch. Deshalb erlaubt der BlockingIntStack einem Thread, der in einem vollen Stack noch ein int-Wert mit push() ablegen will, zu warten, bis ein anderer Thread einen int-Wert mit pop() herausgeholt hat und ihm signalisiert, dass nun wieder Platz im Stack ist. Das Objekt auf dem wait() und notifyAll() aufgerufen wird, ist in der obigen Implementierung immer dasselbe, nämlich this. Dies gilt sowohl für das Warten und Notifizieren bei einem vollen wie auch bei einem leeren Stack. Das Objekt, das für das Warten/Notifizieren genutzt wird, nennt man in Anlehnung an Thread APIs, die vor Java existiert haben, Bedingung (englisch: condition). Das heißt, in unserer Implementierung werden die zwei logischen Bedingungen: nicht mehr voll’ und nicht mehr leer’ an einem einzigen Bedingungsobjekt (nämlich this) kommuniziert.
Ein einziges Bedingungsobjekt für verschiedene logische Bedingungen
zu verwenden ist recht typisch für die Benutzung von wait() und notify()
(bzw notifyAll()) in Java. Warum das so ist, bzw. warum das in den meisten
Fällen sogar so sein muss, wollen wir in diesem Artikel untersuchen.
Das Nested-Monitor-ProblemFangen wir damit an, dass wir versuchen den BlockingIntStack so zu ändern, dass er je ein Bedingungsobjekt für "nicht mehr voll" und "nicht mehr leer" hat. In der ursprünglichen Implementierung wurde this als Bedingungsobjekt verwendet. Da wir nun zwei Bedingungsobjekte verwenden wollen, können wir this allein nicht mehr verwenden. Statt dessen werden wir zwei zusätzliche private Attribute als Bedingungsobjekte nutzten, so dass unsere Klasse folgendermaßen aussieht (push() und pop() fehlen noch):public class BlockingIntStackWithTwoCondtions {Bevor wir zur Implementierung von push() und pop() kommen, wollen wir uns noch einmal in Erinnerung rufen, dass das Mutex des Bedingungsobjekt gesperrt sein muss, bevor wait() oder notifyAll() (bzw. notify()) darauf aufgerufen werden kann. Das bedeutet, dass die jeweiligen Aufrufe von wait() und notifyAll() in einem entsprechenden synchronized-Block stehen müssen, der fullCondition bzw. emptyCondition als Mutex nutzt. Gleichzeitig benötigen wir die Sperre eines Mutex, um den Stackpointer cnt und den Speicher array atomar zu manipulieren (siehe / KRE1 /). Eine mögliche Implementierung von push() und pop(), die diesen Anforderungen gerecht wird, sieht folgendermaßen aus: public void push (int elm) throws InterruptedException {Hier wird jeweils die gesamte Methode mit beiden Mutexen gesperrt. Wichtig ist dabei, dass das Sperren der Mutexe in beiden Methoden in gleicher Reihenfolge geschieht, sonst besteht potenziell die Gefahr eines Deadlocks. Deshalb ist jeweils der synchronized-Block mit emptyCondition in den synchronized-Block mit fullCondition geschachtelt. Obwohl der obige Code ganz gut aussieht und alle bisher beschriebenen Anforderungen erfüllt, funktioniert er aus äußerst subtilen Gründen trotzdem nicht. Bevor wir diese Gründe im Detail diskutieren, erinnern wir uns noch einmal daran, was wir über das Zusammenspiel von wait() und notify() (bzw. notifyAll()) in unserem letzten Artikel gesagt haben: der Thread gibt implizit das Mutex des Bedingungsobjekts frei, wenn er wait() darauf aufruft. Dies ist auch unbedingt nötig, damit der Thread, der notify() (bzw. notifyAll()) aufrufen möchte, das Mutex sperren kann, um danach notify() (bzw. notifyAll()) aufrufen zu können. Detaillierter kann man dies im Sequenzdiagram in Abbildung 1 sehen. Es zeigt einen Produzenten-Thread, der an einem vollen BlockingIntStack (mit nur einer Bedingung) darauf wartet, dass ein Konsumenten-Thread einen Wert herausnimmt. Abbildung 1: Synchronisation über wait() und notifyAll() unter Verwendung von nur einem Bedingungsobjekt Funktioniert diese Synchronisation in der gleichen Situation mit unserem BlockingIntStackWithTwoConditions? Nein, leider nicht. Der Produzenten-Thread sperrt erst das fullCondition Mutex und dann das emptyCondition Mutex. Da der Stack bereits voll ist, kommt er in die while-Schleife und ruft fullCondition.wait() auf. Damit gibt der das fullCondition Mutex frei. Man beachte: er hält weiterhin das emptyCondition Mutex. Ruft nun der Konsumenten-Thread pop(), so versucht er nacheinander das fullCondition Mutex und dann das emptyCondition Mutex zu sperren. Beim emptyCondition Mutex bleibt er hängen, weil dies noch dem Produzenten-Thread gehört. Dies ist dann auch der vollständige Stillstand dieses aus Produzenten und Konsumenten bestehenden Systems. Abbildung 2 zeigt diesen Ablauf in einem Sequenzdiagram.
Das gerade beschriebene Problem ist eine wohlbekannte Tatsache in der Java-Multithread-Programmierung - eine Art Anti-Pattern mit dem Namen Geschachtelter-Monitor-Problem (englisch: nested monitor problem). Skeptiker zweifeln an dieser Stelle vielleicht daran, dass die obige Implementierung von push() und pop() bzgl. des Problems repräsentativ ist. Schließlich gibt es ein ganze Reihe von Variationsmöglichkeiten und darunter könnte ja eine sein, die dieses Problem nicht hat und außerdem noch korrekt ist. Trotzdem gibt es keine Lösung. Wie man es auch dreht und wendet, das Nested-Monitor-Problem läuft im Prinzip immer darauf hinaus, dass beim Aufruf von wait() nur das Mutex desjenigen Bedingungsobjekts frei gegeben wird, auf dem wait() aufgerufen wird. Wenn aber mehrere Bedingungsobjekte verwendet werden, dann werden immer auch mehrere Mutexe gehalten; das liegt an der in Java (pre-JDK 1.5) eingebauten 1:1-Beziehung zwischen Bedingung und Mutex, bei der jedes Bedingungsobjekt immer genau das eigene Mutex verwendet. Das Problem läßt sich vermeiden, wenn man mit nur einem Bedingungsobjekt (und seinem Mutex) arbeitet und daran zwei logische Bedingungen kommuniziert werden. Womit wir wieder beim BlockingIntStack vom Anfang wären.
Abweichend von unserer Implementierung des BlockingIntStack mit
einem Bedingungsobjekt ist es natürlich möglich, als Bedingungsobjekt
nicht this sondern ein explizites privates Attribut zu verwenden. Die Vor-
bzw. Nachteile bei der Benutzung eines expliziten privaten Bedingungsobjekts
sind im wesentlichen die gleichen wie die bei der Benutzung eines expliziten
privaten Mutexobjekt. Wir haben diese Situation bereits in einem der vorhergehenden
Artikel (siehe /
KRE2
/) diskutiert.
Das Nested-Monitor-Problem in der Java-ProgrammierpraxisDie Ursachen der Blockade, in die wir mit unsrem BlockingIntStackWithTwoCondtions gekommen sind, waren relativ einfach zu analysieren, weil beide Bedingungen in derselben Klasse genutzt werden und damit im Code relativ nah beieinander liegen. Ein Blick auf die push() oder die pop()-Methode genügt, um die geschachtelten synchronized Blöcke (die die geschachtelten Monitore repräsentieren) zu erkennen.
Das ist in der Praxis nicht immer so einfach. Betrachten wir eine andere
Situation. Nehmen wir zum Beispiel eine Klasse A, deren Methoden mit dem
Mutex von this synchronisiert sind. Diese Klasse enthält ein Attribut
der Klasse B, die als Bedingungsobjekt wieder this (aber diesmal das des
Attributs vom Typ B) nutzt.
Hier haben wir ebenfalls einen geschachtelten Monitor, wenn die synchronisierten Methoden der äußeren Klasse A die Methoden aus B aufrufen, die ihrerseits wait() und notify() (oder notifyAll()) aufrufen. Das Sequenzdiagramm in Abbildung 3 zeigt den Ablauf. Es wird zwar nur eine logische Bedingung verwendet (in der Klasse B), aber es werden zwei Monitore (nämlich der des äußeren A -Objekts und der des enthaltenen B -Objekts) benutzt. Der Aufruf von wait() in der Klasse B gibt nur das Mutex von B frei, aber der andere Thread wartet noch immer auf das Mutex von A und wird niemals dazu kommen, eine Methode von B aufzurufen, die die erwartete Zustandsänderung herbeiführen könnte. Wir haben also wieder ein Nested-Monitor-Problem. ZusammenfassungIn dieser Ausgabe haben wir uns die Synchronisation von mehreren Threads über wait() und notify() (bzw. notifyAll()) angesehen. wait() und notify() werden verwendet, um zustandsabhängige Aktionen zu implementieren. Wenn eine Aktion abhängig vom Zustands eines Objekts nicht ausgeführt werden kann, dann kann in einer Multithread-Umgebung auf eine Zustandsänderung gewartet werden, statt die Aktion mit einer Fehlerindikation sofort abzubrechen. Die Idee der Kommunikation über wait() und notify() besteht darin, dass ein (oder mehrere) Threads auf die Zustandsänderung warten und ein (oder mehrere) andere Threads ein Signal senden, wenn sie die Änderung herbei geführt haben.Dabei sind diverse Details zu beachten. Wir haben den Unterschied zwischen notify() und notifyAll() diskutiert. Dabei hat sich herausgestellt, dass die robusteste Art der Verwendung von wait() und notify() darin besteht, in einer while-Schleife den Zustand abzufragen und danach per wait() zu warten, während der signalisierende Thread die Benachrichtigung per notifyAll() an alle wartenden Threads (und nicht per notify() an nur genau einen Thread) versendet. Abbildung 3: Nested-Monitor-Problem bei Verwendung von zwei Mutexen Eine getrennte statische Analyse der beiden Klassen A und B enthüllt das Problem allerdings nicht, da das Thread-Verhalten von dem dynamischen Ablauf (welche Mutexe wurden wann gesperrt?) und weniger von der statischen Struktur innerhalb der einzelnen Klasse abhängt. Dies ist ein Beispiel dafür, dass die Ideen der Objektorientierung (Datenkapselung in Abstraktionen, Polymorphismus in Klassenhierarchien, etc.) und die Ideen der Multithread-Welt eher orthogonal zueinander stehen, als sich zu ergänzen. Verwirrend ist dabei, dass in Java im Vergleich zu anderen Programmiersprachen die statische Struktur innerhalb einer Klasse noch eine relativ große Rolle spielt, da Sperren ausschließlich an Block- bzw. Methodengrenzen aktiviert und deaktiviert werden können (zumindest bis zum JDK 1.5). Wenn also die statische Analyse eines Nested-Monitor-Problems relativ mühselig ist und eine gewisse Erfahrung mit der Multithread-Programmierung voraussetzt, stellt sich automatisch die Frage, ob es andere Möglichkeiten der Fehleranalyse gibt. Verschiedene Tools (siehe Randbemerkun g "Hilfsmittel für die Deadlock-Analyse") bieten die Möglichkeit, sich die von einem Thread gesperrten Mutexe sowie die Gründe für das Warten eines Threads (Warten auf Mutex-Sperren, Warten an einer Bedinung, usw.) anzusehen. Das heißt, wenn es einen "Hänger" auf Grund eines geschachtelten Monitors gibt, dann kann man sich die wartenden Threads und die von ihnen eventuell gehaltenen Sperren sowie die Gründe für ihr Warten ansehen. Bei richtiger Zuordnung dieser Informationen und dem Wissen darüber, welcher der wartenden Threads potenziell notify() bzw. notifyAll() auf der fraglichen Bedingung aufgerufen haben könnte, lassen sich dann meist die Ursachen für den “Hänger“ finden. Obwohl Tools, mit denen man sich die Sperren eines Threads ansehen kann, meist auch die Fähigkeit haben, Deadlocks selbständig zu finden, ist die manuelle Analyse im Fall eines Nested-Monitor-Problems unumgänglich. Denn ein geschachtelter Monitor ist im eigentlichen Sinne kein echter Deadlock. Bei einem echten Deadlock wartet ein Thread auf die Freigabe einer Sperre (englisch: lock), die von einem anderen Threads gehalten wird, aber nicht mehr freigegeben wird, weil dieser andere Thread seinerseits auf den ersten Thread wartet. Beim geschachtelten Monitor ist die Situation etwas anders: der Thread, der wait() aufgerufen hat, wartet auf keine Sperre, sondern auf ein Signal, dass ein anderer Thread per notify() oder notifyAll() senden müßte. Es ist in der Regel notwendig, den Sourcecode manuell zu analysieren, um zu erkennen, welcher andere Thread potenziell in der Lage wäre, notify() oder notifyAll() auf der entsprechenden Bedingung aufzurufen. Es reicht nicht aus, lediglich die Sperren zu analysieren.
Und was macht man, wenn man bei der Fehleranalyse festgestellt hat,
dass der Grund für den Fehler ein geschachtelter Monitor ist? Man
wird versuchen, an beiden Stellen nur ein Mutex- bzw. Bedingungsobjekt
zu nutzen. Schauen wir uns das Beispiel mit den Klassen A und B noch einmal
an. Dort waren das Mutex- bzw. Bedingungsobjekt das this des Objekts vom
Typ A und das this des Attributs b vom Typ B. Eine Lösung könnte
so aussehen, dass wir das this des Objekts vom Typ A in beiden Fällen
als Mutex- bzw. Bedingungsobjekt nutzten. Das bedeutet, das die Klasse
B kein hardcodiertes Mutex- bzw. Bedingungsobjekt mehr nutzen darf, sondern
dieses als Parameter im Konstruktor übergeben bekommt. Das Codefragment
einer solchen Lösung sähe so aus:
In der Praxis ist es leider so, dass die Voraussetzungen für die
Beseitigung des Nested-Monitor-Problems häufig gar nicht gegeben sind.
Dazu müssten Klassen so aussehen wie die Klasse B in unserem Beispiel:
sie dürften kein hardcodiertes Mutex- bzw. Bedingungsobjekt verwenden,
sondern müßten wir oben gezeigt in der Lage sein, ein beliebiges
von Außen übergebenes Mutex- bzw. Bedingungsobjekt benutzen.
Schaut man sich aber existierende Klassen an, beispielsweise die Klassen
des JDK an, die Synchronisation benutzen, so stellt man fest, dass sie
hardcodierte Mutex- bzw. Bedingungsobjekte verwenden. Diese wenig flexible
Implementierungstechnik wird im allgemeinen auch bei benutzerdefinierten
Klassen verwendet, was letztendlich dazu führt, dass man in der Regel
Zugriff auf den Sourcecode der beteiligten Klassen haben muss, um ein bereits
identifiziertes Nested-Monitor-Problem zu korrigieren.
JDK 1.5 und das Nested-Monitor-ProblemBisher haben wir uns die Problematik des geschachtelten Monitors aus der Pre-JDK-1.5-Perspektive angesehen. Das heißt, was wir bisher gesehen haben, gilt für die Java Multithread-Programmierung mir einem JDK vor 1.5. Mit dem JDK 1.5 ist es zu einer umfassenden Erweiterung des Multithread-APIs in Java gekommen. Die neuen expliziten Sperren haben wir ja bereits diskutiert (siehe / KRE3 /). An dieser Stelle wollen wir auf die Änderungen eingehen, die das Nested-Monitor-Problem in eine neue Perspektive rücken.Ein Grund für die Erweiterung des Multithread-API im JDK 1.5 war unter anderem die Absicht, den Java API näher an einen wesentlichen Multithread-API Standard zu bringen, den es bei der Benutzung von C und C++ Programmen gibt: Posix Threads oder kurz P-Threads (siehe / POSIX /). Bei P-Threads ist es möglich, anders als bisher in Java, dass man mehrere Bedingungen mit einem Mutex verbindet. Das wird mit dem JDK 1.5 auch in Java möglich werden. Das heißt, es ist dann möglich, mehrere Bedingungsobjekte zu einem einzigen Mutex zu haben. Damit kann man dann auch einen BlockingIntStackWithTwoCondtions korrekt implementieren. Bevor wir das machen, schauen wir uns die Details aus dem JDK 1.5 API an. Die Verbindung von mehreren Bedingungsobjekten mit einem Mutex läßt sich mit expliziten Sperren und Bedingugen erreichen. Die expliziten Sperren haben wir bereits in / KRE3 / besprochen. Sehen wir uns an dieser Stelle die expliziten Bedingungen an. Im Package java.util.concurrent.locks, das neu im JDK 1.5 hinzukommen ist, gibt es ein Interface Condition, das die Funktionalität unterstützt, die man von einer Bedingung im Multithread-Kontext erwartet. Die Methoden des Interfaces Condition sind:
Es gibt im neuen Package java.util.concurrent.locks allerdings keine Klasse, die das neuen Interface Condition implementiert. Es stellt sich daher die Frage: woher bekommt man ein Bedingungsobjekt? Offensichtlich nicht durch den Aufruf eines Konstruktors irgendeiner Klasse, die das Interface Condition implementiert. Stattdessen werden explizite Bedingungen über explizite Sperren erzeugt. Da Bedingungsobjekte immer mit einem Mutex verbunden sein müssen, stellen die expliziten Sperren eine Factory-Methode für Bedingungsobjekte zur Verfügung. Das Interface Lock im Package java.util.concurrent.locks hat eine Methode newCondition(), die ein Bedingungsobjekt liefert. public Condition newCondition();Von welchem Typ das Bedingungsobjekt ist, bleibt unbekannt und ist auch nicht wichtig, Relevant ist nur, dass das gelieferte Bedingungsobjekt mit dem Mutex der expliziten Sperre verbunden ist, von der es erzeugt wurde. Mit diesem Hilfsmittel und unter Berücksichtigung der vorhergehenden Überlegungen zum Nested-Monitor-Problem können wir den BlockingIntStackWithTwoCondtions_JDK_1_5 folgendermaßen implementieren: public class BlockingIntStackWithTwoCondtions_JDK_1_5 {Mit dem JDK 1.5 ist es also nun möglich, jede logische Bedingungen durch ein eigenes Bedingungsobjekten zu repräsentieren. Der Vorteil einer 1:1-Abbildung von logischen Bedingungen auf Bedingungsobjekte ist, dass die logische Struktur des Programms deutlicher und expliziter dargestellt werden kann. Ein Blick auf den BlockingIntStack vom Anfang dieses Artikels und auf den BlockingIntStackWithTwoCondtions_JDK_1_5 oben sollte das deutlich machen: der Code ist bedeutend transparenter, wenn nur jeweils eine logische Bedingung über ein Bedingungsobjekt kommuniziert wird. Trotz der neuen expliziten Bedingungsobjekte kann man natürlich immer noch Probleme in Form von geschachtelten Monitoren und Deadlocks produzieren. Es ist nach wie vor wichtig zu verstehen, wie viele Monitore verwendet werden bzw. dafür zu sorgen, dass immer nur ein Mutex gesperrt und entsperrt wird und verschiedene Bedingungsobjekte gemeinsam dasselbe Mutex verwenden müssen, um Probleme zu vermeiden. In unserer Implementierung oben tun wir das auch: beide Bedingungsobjekte fullCondition und emptyCondition sind an das eine Mutex der expliziten Sperre lock gekoppelt. In anderen Situationen, wie wir sie am Beispiel der Klassen A und B diskutiert haben, ändert sich auch unter Verwendung von JDK 1.5 nichts. Im Beispiel der Klassen A und B hatten wir ohnehin nur eine einzige Bedingung verwendet und das Problem lag in der Verwendung von zwei Mutexen. Die neuen Abstraktionen des JKD 1.5 helfen in solchen Situationen nicht. Man muß immer noch analysieren und verstehen, welche Mutexe verwendet werden und dafür sorgen, dass nur ein Mutex gemeinsam verwendet wird. Wie das im Falle der Klassen A und B geht, haben wir ja schon gezeigt. ZusammenfassungIn diesem Artikel haben wir uns das Problem geschachtelter Monitore angesehen. Es tritt auf, wenn mehrere Bedingungsobjekte und mehrere Mutexe verwendet werden. Die Verwendung von mehreren Bedingungen ist eigentlich durchaus erwünscht, weil sie zu einer klareren Programmstruktur führt, bei der jede logische Bedingung durch eine eigenes Bedingungsobjekt ausgedrückt wird. Mit dem klassischen Multithread-Support (vor JDK 1.5), bei dem jedem Bedingungsobjekt genau ein Mutex zugeordnet ist, führt die Verwendung von mehreren Bedingungsobjekten aber immer zum Nested-Monitor-Problem. Das Nested-Monitor-Problem äußert sich durch den Stillstand der Applikation und ist unerwünscht. Lösen lässt sich das Problem durch Abbildung von mehreren logischen Bedingungen auf ein einziges Bedingungsobjekt (unschöne Programmstruktur) oder mit dem JDK 1.5 durch die Verwendung von expliziten Condition-Objekten.
Literaturverweise
|
|||||||||||||||||||||||||||||||||
© Copyright 1995-2008 by Angelika Langer. All Rights Reserved. URL: < http://www.AngelikaLanger.com/Articles/EffectiveJava/16.NestedMonitorProblem/16.NestedMonitorProblem.html> last update: 26 Nov 2008 |