Zurück zum Inhaltsverzeichnis des Manuskripts verteilte Systeme

8.2.2 Arbeiten mit Sperrverfahren

Java-synchronized-Blöcke

Der erste Lösungsansatz für das Produzenten/Konsumenten-Problem war falsch, weil sich Produzent und Konsument nicht gegenseitig davon abgehalten haben, auf das Lager zuzugreifen. Aber genau das hätte geschehen müssen. Die Sperrmechanismen, die Programmierern nebenläufig arbeitender Anwendungen zur Verfügung stehen, sind von der gewählten Programmierumgebung abhängig und beziehen möglicherweise Konzepte der zugrundeliegenden Hardware und Systemarchitektur ein. Darstellungen dieser Konzepte gehören in den Bereich der Betriebssysteme und finden sich in der entsprechenden Literatur wie beispielsweise in

Bild: Literatur Tanenbaum

Für die Lehrveranstaltung soll es genügen, Koordinierungskonzepte für nebenläufig arbeitende Threads am Beispiel einer Java-Programmierung vorzustellen. Im Abschnitt 1.3.3 (Threadsicherheit) ist ein in Java vorhandenes Sperrverfahren vorgestellt worden, das mit dem synchronized-Sprachkonstrukt arbeitet. In Java besitzt jede Klasse und jede Instanz einer Klasse eine Sperre, die durch das synchronized-Konstrukt ununterbrechbar gesetzt und wieder aufgehoben wird. Im Folgenden werden nur Sperren von Instanzen (Objekten) benutzt. Ein Sperrvorgang mit der Instanzsperre eines Strings sieht beispielsweise folgendermaßen aus:

String abc = "Hallo"; synchronized(abc) { Java-Befehle; }

Durch den synchronized-Aufruf wird (anschaulich durch die öffnende geschweifte Klammer) die Sperre des Objekts abc gesetzt. Verlässt der Kontrollfluss den synchronized-Block bei der schließenden geschweiften Klammer oder durch einen der darin enthaltenen Java-Befehle, dann wird diese Sperre aufgehoben. Wollen sich zwei oder mehr Threads gegenseitig ausschließen, dann müssen sie die Sperre von ein und demselben Objekt benutzen. Um dies sicherzustellen, können sogenannte synchronized-Methoden verwendet werden, die die Sperre der Instanz this verwenden. Ein Beispiel ist die folgenden Methode, die einen Zähler i um 1 erhöht und den neuen Wert zurückgibt:

synchronized addEins(int i) { i++; return i; }

Wirkung einer Sperre

Das Setzen einer Sperre kann nicht - insbesondere nicht durch ein Time Sharing des Betriebs- oder Laufzeitsystems - unterbrochen werden. Ist die Sperre gesetzt, kann die Abarbeitung der Befehle im synchronized-Block jedoch durchaus unterbrochen werden. Das heißt, dass die gesetzte Sperre von den anderen Threads respektiert werden muss, sonst wirkt sie nicht. Ein anderer Thread respektiert diese Sperre, indem er einen synchronized-Aufruf mit genau demselben Objekt durchführt, das die Sperre trägt.

Durch einen synchronized-Aufruf auf ein bestimmtes Objekt wird entweder die Sperre dieses Objekts gesetzt oder, falls sie bereits gesetzt ist, der aufrufende Thread solange blockiert (dem Scheduling entzogen), bis die Sperre wieder freigegeben worden ist. Dadurch können Java-Threads sich gegenseitig von der Ausführung bestimmter Befehle abhalten. Dieses Verfahren soll jetzt benutzt werden, um zu einem zweiten Ansatz für die Lösung des Produzenten/Konsumenten-Problems zu kommen.

Java-Umsetzung

Die vorliegende Umsetzung des Produzenten/Konsumenten-Problems in ein Java-Programm folgt einem minimalistischen Prinzip, um nicht durch Nebensächlichkeiten den Blick auf das Wesentliche zu versperren. Implementiert wird ein Java-Prozess, dessen main-Thread einen Produzenten- und einen Konsumenten-Thread startet und dann auf beider Ende wartet. Als zu erzeugende und zu verbrauchende Artikel werden Integerzahlen verwendet, die aus dem Intervall [1, 9] stammen. Die Produktion besteht darin, eine dieser Zahlen zufällig auszuwählen, der Verbrauch darin, eine Zahl an der Konsole auszugeben. Als Lager wird eine Klasse gebildet, die ein 5-stelliges int-Array für die zu speichernden Artikel, eine int-Variable z als Füllstandanzeiger und zwei synchronized-Methoden put() und get() für den Zugriff auf das Lager enthält.

Um bei der praktischen Umsetzung Endlosschleifen zu vermeiden, bringt der Produzent nach 10 Produktionsschritten die Zahl -1 ins Lager und beendet sich dann. Stößt der Konsument auf diese Zahl, dann entnimmt er dem Lager die dort noch vorhandenen Artikel, verbraucht sie und beendet sich dann ebenfalls.

Zweiter Lösungsansatz

Es folgt eine Auflistung der Programme:

Der zweite Ansatz ist ebenfalls falsch

Auch der zweite Ansatz ist falsch, obwohl sich die beiden Threads gegenseitig vom Betreten des kritischen Abschnitts ausschließen. Das liegt daran, dass die Sperre in den beiden Lager-Methoden put() und get() nicht korrekt gesetzt wird. Es kann nämlich passieren, dass das Lager zu irgendeinem Zeitpunkt zufällig ganz voll (oder zufällig ganz leer) geworden ist. Angenommen, es sei ganz voll und der Produzent will einen gerade produzierten Artikel dort ablegen, während der Konsument gerade einen Artikel verbraucht. Dann sperrt die put()-Methode des Produzenten das Lager, stellt fest, dass dieses voll ist und bleibt in der while-Schleife ohne die Sperre jemals wieder freizugeben. Der Konsument kann nicht mehr auf das Lager zugreifen, er ist ausgesperrt worden. Ganz analog kann so etwas passieren, wenn das Lager leer ist und der Konsument versucht, einen Artikel zu entnehmen. Seine get()-Methode sperrt dann den Produzenten aus.

Dritter Lösungsansatz

Beim Setzen von Sperren muss deren Wirkung beachtet werden. Der zweite Lösungsansatz ist falsch, weil sich die endlose while-Schleife (die Warteschleife) jeweils innerhalb der synchronized-put()- bzw. -get-Methode befindet. Wird sie in den Produzenten bzw. in den Konsumenten verlegt, dann ist die Sperre korrekt gesetzt. Dies geschieht im folgenden dritten Ansatz, dessen Programme hier angegeben sind:

Der dritte Ansatz sperrt korrekt, und ist dennoch nicht akzeptabel

Der vorgestellte dritte Lösungsansatz des Produzenten/Konsumenten-Problems ist trotz seiner korrekt gesetzten Sperre aus zwei Gründen für eine professionelle Softwarentwicklung nicht akzeptabel.

  1. Das Warten der beiden Threads in Endlosschleifen wird als beschäftigtes Warten oder auch als aktives Warten bezeichnet. In der englischsprachigen Literatur spricht man von einem Busy Waiting. Es verbraucht Rechenzeit ohne irgendetwas Nützliches zu tun. Derartige Programme sollten vermieden werden.

  2. Dadurch, dass die Warteschleifen (die beiden endlosen while-schleifen) außerhalb des gesperrten Bereichs angesiedelt worden sind, können sie durch das Time Sharing des Betriebs- oder Laufzeitsystems unterbrochen worden, was zu einem Lost Update führen kann, wie im Abschnitt 1.3.3 (Threadsicherheit) gezeigt worden ist. Das hat bei der hier gewählten Variante des Produzenten/Konsumenten-Problems mit genau einem Produzenten und genau einem Konsumenten keine Auswirkung, weil hier durch ein Lost Update der Füllstandanzeiger in eine unschädliche Richtung verschoben wird. Bei der Problemvariante mit mehreren Produzenten und Konsumenten ist die angegebene Lösung jedoch falsch.

Vierter Lösungsansatz

Um das beschriebene Manko des dritten Lösungsansatzes für das Produzenten/Konsumenten-Problem zu beseitigen, bedarf es eines Verfahrens, mit dessen Hilfe Threads innerhalb einer Sperre dem Scheduling des Betriebs- oder Laufzeitsystems entzogen werden können und dabei die zugehörige Sperre aufheben, so dass ein anderer Thread in seinen zugehörigen gesperrten Bereich eintreten kann. Dann muss es aber auch möglich sein, solche dem Scheduling entzogenen Threads wieder an die Stelle im Programm zurückzubringen, also in einen gesperrten Bereich mit erneutem Setzen der Sperre, an dem sie dem Scheduling entzogen worden sind.

Dazu wurde in Java der synchronized-Mechanismus so realisiert, dass beim Setzen einer Sperre eines Objekts (oder einer Klasse) zu diesem Sperrobjekt (zu dieser Sperrklasse) eine anfangs leere Warteschlange für Threads eingerichtet wird. Und es wurden die folgenden Methoden implementiert, die mit dieser Warteschlange arbeiten:

wait()

Die direkt aus der Klasse Object des java.lang.Pakets abgeleitete Instanzmethode wait() gibt es in drei Ausprägungen, von denen zwei als Parameter eine Zeitangabe für einen Timeout enthalten. Hier soll die Ausprägung ohne Parameter verwendet werden:

public final void wait() throws InterrutedException

Die Methode kann nur innerhalb eines synchronized-Blocks (einer synchronized-Methode) zu dem jeweiligen Sperrobjekt aufgerufen werden. Ihr Aufruf entzieht den aufrufenden Thread dem Scheduling und bringt ihn in die zu dem Sperrobjekt (der Sperrklasse) gehörende Warteschlange. Dabei wird die zugehörige Sperre aufgehoben. Ein solcher Thread, der in der Warteschlange zu einem Sperrobjekt wartet, kann sich nicht selbst aus dieser Lage befreien, denn dazu bräuchte er Rechenzeit, die er nicht bekommt, weil er dem Scheduling entzogen ist. Ist ein Selbstbefreien vorgesehen, dann muss eine der beiden wait()-Methoden mit einer Timeout-Angabe im Parameter verwendet werden.

notify()/notifyAll()

Auch ein notify()-Aufruf kann nur innerhalb eines synchronized-Blocks (einer synchronized-Methode) erfolgen und wird auf das zugehörige Sperrobjekt angewandt. Mit ihm wird irgendeiner (!) der in der Warteschlange zu diesem Sperrobjekt wartenden Threads wieder dem Scheduling zugeführt, d.h. aus dem Warten befreit. Sobald er Rechenzeit hat, wird die Sperre des Sperrobjekts wieder gesetzt und der Thread arbeitet in seinem Programm direkt nach dem wait()-Befehl weiter, allerdings erst, nachdem der Thread, der notify() aufgerufen hat, seinen gesperrten Bereich verlassen hat. Das heißt, dass in einem Programm der notify()-Befehl nicht zwingend der letzte Befehl eines synchronized-Blocks sein muss. In der Programmierpraxis ist er dies jedoch oft aus Gründen der Übersichtlichkeit. Ein Thread, der in einen gesperrten Bereich zurückkehrt, muss in der Regel sofort seine Wartebedingung prüfen und gegebenenfalls den gesperrten Bereich wieder verlassen. Gibt es in der Warteschlange keinen wartenden Thread, dann ist der Aufruf wirkungslos ohne dass eine Fehlersituation entstünde. Gibt es mehr als einen wartenden Thread, dann ist nicht vorhersagbar, welcher davon aus dem Warten befreit wird.

Neben notify() gibt es einen notifyAll()-Aufruf, der im Prinzip wie ein notify()-Aufruf arbeitet. Im Gegensatz zu diesem befreit er immer alle wartenden Threads aus ihrem Wartezustand. Diese konkurrieren dann miteinander um die Rückkehr in ihren gesperrten Bereich. Der notifyAll()-Aufruf deckt Fälle ab, bei denen mehrere wartende Threads laufbereit gemacht werden sollen, um sicherzustellen, dass nicht immer nur ein zufällig ausgewähler wartender Thread (vielleicht immer derselbe) laufbereit wird.

Im Folgenden sind die Programme des vierten Lösungsansatzes angegeben. Mit ihnen liegt jetzt (endlich) eine korrekte und akzeptable Lösung des Produzenten/Konsumenten-Problems vor. Die vielen erfolglosen Lösungsversuche zeigen einige der Schwierigkeiten, mit denen bei der Koordinierung nebenläufig arbeitender Threads gerechnet werden muss.

Hinweis zum Warten im Lager

Bei der vorgestellten Lösung erfolgt das Warten im Lager sowohl in der put()- als auch in der get()-Methode in einer while-Schleife:

while(z == ...) { try { wait; } ... }

Das führt zu der häufig gestellten Frage, ob ein if anstelle des while hier nicht ausreichend ist. Die Antwort lautet nein! Dazu führt die Java-Dokumentation der Firma Oracle zum wait()-Aufruf Folgendes aus:

A thread can wake up without being notified, interrupted, or timing out, a so-called spurious wakeup. While this will rarely occur in practice, applications must guard against it by testing for the condition that should have caused the thread to be awakened, and continuing to wait if the condition is not satisfied. Quelle (Abruf am 03.04.2020): https://docs.oracle.com/en/java/javase/14/docs/api/java.base/java/lang/Object.html#wait()

Rechnerübergreifende Threadkommunikation

Man beachte, dass der hier vorgestellte Sperrmechanismus darauf beruht, dass die beteiligten Threads die Sperre von ein und demselben Objekt (oder von ein und derselben Klasse) benutzen müssen. Ganz ähnlich ist das bei den anderen hier nicht behandelten betriebssystemabhängigen Sperrverfahren. Um Threads rechnerübergreifend zu koordinieren, müssen die Sperren lokal gesetzt und ihr Setzen und Freigeben durch Sperrprotokolle gesteuert werden. Eine Behandlung solcher Verfahren geht über den Rahmen der Lehrveranstaltung hinaus.



Zurück zum Inhaltsverzeichnis des Manuskripts verteilte Systeme