 Ja, herzlich willkommen zurück zu Programmierparadigmen. Was wir in diesem vierten und letzten Teil des Themenblocks Concurrency machen wollen, ist, uns bestimmte Konstrukte in Programmiersprachen angucken, mit denen man die Ausführung von parallelen Threads synchronisieren kann, um Data Races zu verhindern oder um auch die Überraschung, die das Java Memory Model anderenfalls vielleicht bereithält, zu vermeiden. Verschiedene Programmiersprachen bieten da natürlich verschiedene Konstrukte an und was wir hier machen wollen, ist uns auf fünf Arten von solchen Konstrukten zu beschränken, nämlich zum einen Monitors, dann die sogenannten Conditional Critical Regions. Dann schauen wir uns die verschiedenen Synchronisationskonstrukte, die Java hat, ein bisschen genauer an, die Idee von Transactual Memory und schlussendlich implizite Synchronisationskonstrukte, die man als Programmierer vielleicht gar nicht so explizit mitbekommt, die aber natürlich trotzdem auch in Programmiersprachen existieren. Als erstes Konstrukt, was es in manchen Sprachen gibt, schauen wir uns mal die sogenannten Monitors an. Also im Prinzip ist ein Monitor einfach nur ein bestimmtes Arten spezielles Objekt, das drei Dinge anbietet, zum einen hat es Operation, so ähnlich wie ein objektorientiertes Sprachen eigentlich immer, dann gibt es einen gewissen internen Zustand, so wie man das eigentlich auch kennt und zusätzlich gibt es noch bestimmte Variablen, die Bedingungen festlegen, dann die Ausführungen weitergehen darf. Was jetzt besonders ist an diesen Monitors ist, dass jeweils nur eine Operation von den Operationen, die dieser Monitor anbietet, aktiv sein kann, also da gibt es sozusagen eine Mutual Exclusiveness Garantie innerhalb dieses Monitors, die sagt immer nur eine Operation wird pro Zeitpunkt tatsächlich ausgeführt wird. Was passiert ist also, wenn ich eine Operation eines Monitors aufrufe, während vielleicht eine andere Operation gerade am Ausführen ist, dann wird die aktuelle oder die aufgerufene Operation einfach verzögert und es dann ausgeführt, wenn die vorherige Operation fertig ist, so dass eben immer nur eine Operation zur gleichen Zeit ausgeführt wird. Zusätzlich zu den üblichen Sachen Operationen und Zuständen gibt es eben jetzt auch noch diese Bedingungsvariablen und die sind dafür da, dass eine Operation in drin warten kann, bis eine bestimmte Bedingung zutrifft und eine andere Operation kann ein Signal schicken an die eventuell wartenden Operationen und sagen, dass jetzt eine bestimmte Bedingung so ist, wie sie nun ist und das gegebenenfalls andere Operationen dann an der Stelle weitermachen können, wo sie vorher aufgehört haben. Schauen wir uns mal ein konkretes Beispiel an, und zwar eine monitorbasierte Imlimitation von einem Bounded Buffer, also so ein Bounded Buffer, also das sieht einfach nur inzwischen Speicher von einer bestimmten Größe, wo ein Thread oder mehrere Threads gleichzeitig Dinge reinschreiben können und andere Threads können Dinge rausholen. Und was der Buffer sicherstellt, ist, dass jedes Element, was reingeschrieben wird, nur einmal rausgeholt wird und dass die Größe des Buffers nie über die Maximalgröße die festgelegtes hinausgeht. Um das zu machen, haben wir hier gewisse Felder, also Zustände dieses Bounded Buffers, zum einen ein Array einer gewissen Größe, sei es, wo die Daten an sich drinstehen, dann haben wir zwei Indizes, NextFull und NextEmpty, die angeben, wo das nächste Element drinsteht, das man rausholen könnte, das ist was NextFull macht und NextEmpty gibt an, wo das nächste Element ist, in das man reinschauen kann. Und dieser Buffer wird intern als zyklische Buffer implementiert, das heißt, wenn ich am Ende des Arrays angekommen bin und aber der Anfang schon wieder ausgelesen wurde, also rausgenommen wurde, dann schreibe ich einfach am Anfang die nächsten Sachen wieder rein. Dann haben wir hier eine zustandsvariable Full Slots, die angeben, wie viele Slots gerade voll sind, das ist maximal immer die Anzahl Size und dann haben wir noch zwei dieser Conditions, die wir gleich sehen, wie die benutzt werden, die angeben, ob jetzt gerade ein voller Slot zur Verfügung steht, beziehungsweise ob jetzt gerade ein leerer Slot wieder zur Verfügung steht. So, jetzt schauen wir uns die zwei Operationen an, wie gesagt, kann immer nur eine von diesen beiden gleichzeitig ausgeführt werden, das garantiert uns der Monitor und die erste ist Insert, die ein Datum, die hier nimmt und das dann in den Buffer einfügen möchte. Zunächst schaut diese Insert Operation, ob die überhaupt noch Platz ist, also wenn Full Slots gleich der Größe des Buffers ist, dann ist kein Platz, wir müssen also erst warten, bis irgendwer anders Remove aufruft und uns dann dieses Empty Slot Signal schickt. Und wenn das dann irgendwann gekommen ist oder wenn Full Slots einfach noch kleiner als Size ist, dann können wir diesen Wert D an der Next Empty Stelle in den Buffer einfügen. Aktualisieren dann Next Empty, so dass es wieder auf das nächste freie Element zeigt und inkrementieren unseren Full Slots Counter, so dass wir wissen, dass da jetzt wieder ein Element mehr drin ist. Und dann wird diese Operation auch noch ein Signal senden, nämlich Full Slot, das angibt, hey, jetzt ist wieder was drin, also falls jemand anders gerade drauf fattet, aus dem Slot was rauszulesen, dann kann der andere jetzt weitermachen. Das Gegenstück zu der Insert Operation ist dann die Remove Operation, in der wir ein Datum aus dem Buffer rausnehmen wollen und das dann zurückgeben wollen. Und was hier geschieht, ist Folgendes. Zunächst schauen wir, ob es überhaupt was zum Rausnehmen gibt und falls Full Slots gleich Null ist, also aktuell nichts drin ist, müssen wir warten, bis wir dieses Full Slot Signal von einer anderen Operation bekommen, nämlich praktisch dann von einem anderen Thread, der Insert aufgerufen hat. Wenn wir jetzt aber sehen, dass da was drin ist, was man rausholen kann, dann wird dieses Datum ausgelesen und zwar von dem Index Next Full, der wird dann auch aktualisiert und der Full Slots Counter wird angepasst und schlussendlich signalisieren wir, dass es jetzt wieder einen freien Slot gibt. Also wenn parallel dazu jemand hier oben hängt, weil da gerade was in den Boundable vereinfügen will, dann wird durch dieses Signal hier unten das Weight aufgelöst und das Insert könnte dann an der Stelle weiter ausgeführt werden. Neben den Monitoren, die wir jetzt gerade ein Beispiel gesehen haben, gibt es ein weiteres Konstrukt in manchen Sprachen, mit denen man eine Critical Section beschreiben kann, nämlich sogenannte Conditional Critical Sections. Also Critical Section zur Erinnerung ist einfach ein Stück Code, was nur von einem Thread zu einem bestimmten Zeitpunkt ausgeführt werden soll. Und was so eine Conditional Critical Section macht, ist A, erstmal so eine Section syntaktisch zu definieren. Also man sagt quasi von hier bis hier ist das Stück Code, was zur Critical Section gehört. Und B, dann eine Bedingung festlegen, die wahr sein muss, bevor man in diese Critical Section reingeht. Also man kann quasi sagen, nur wenn diese bestimmte Bedingung gilt, kann das hier ausgeführt werden. Und wenn ich dann drin bin, kann aber auch niemand anders rein. Ein kleines Beispiel in ganz einfachem Pseudokot hier unten, also ich könnte zum Beispiel mit diesem Region Keyword definieren, dass von hier bis hier die Critical Section existiert. Ich kann außerdem sagen, dass es bestimmte Variablen gibt, das habe ich gerade vergessen zu sagen, die nur innerhalb dieser Critical Section überhaupt benutzt werden können. Und dann sage ich hier, was die Bedingung ist, um in die Critical Section reinzukommen. Und was die Sprache dann für mich machen würde, ist zu checken, ob diese Bedingung wahr ist, wenn die wahr ist, führt es die Critical Section aus. Und in dem Critical Section können dann diese Variablen, die hier vorne aufgelistet werden, benutzt werden. Als drittes Beispiel dafür, wie Synchronisierung in Programmiersprachen funktionieren kann, schauen wir uns mal die Konstrukte an, die Java dafür zur Verfügung stellt. Und zwar gibt es im Prinzip drei Arten von Konstrukten, die wir hier anschauen wollen. Das eine sind Locks, die man mit dem Synchronized Keyword benutzen kann. In Java kann jedes Objekt als Mutual Exclusion Lock fungieren. Das heißt, ich kann irgendein beliebiges Objekt nehmen und das sozusagen als Lock verwenden. Und zwar mache ich das mithilfe dieses Synchronized Keywords, mit dem ich das Lock dann sowohl holen als auch wieder freigeben kann. Das Synchronized Keyword kann in zwei Arten und Weisen verwendet werden. Zum einen kann ich Synchronized Blocks haben, wo ich explizit ein Blockcode angebe und sage, das ist das Objekt, was ich als Lock haben muss, um in diesen Blockcode reinzukommen. Oder ich kann auch Synchronized Methods haben, wo ich sage, eine komplette Methode ist eine Critical Section und alles, was da drin ist, kann nur ausgeführt werden, wenn ich das Lock habe, was dem Objekt entspricht, auf dem diese Methode aufgerufen wird. Schauen wir das Ganze mal an einem konkreten Beispiel an, und zwar diesem Java Code, den man hier sieht. Also, was wir hier haben, ist eine Klasse namens FU und in der Klasse gibt es ein Feld F, das ist am Anfang 5, und dann diese Methode add, in der wir einen integer-Wert x nehmen und versuchen den auf unser Feld drauf zu addieren und dann das aktualisierte Feld zurückzugeben. Diese add Methode der Klasse FU wird jetzt hier unten verwendet, und zwar innerhalb dieser beiden Threads, T1 und T2, die starten wir dann parallel und warten auch bis die wieder fertig sind. Das heißt, der Code, der hier jeweils in den Run Methoden drin ist, wird dann parallel ausgeführt. Das heißt, wir haben parallel zueinander zwei Aufrufe von add, die einmal 3 und einmal 2 hinzuaddieren. Na, jeweils würde man jetzt erwarten, dass dann anschließend hier unten 10 rauskommt, denn 5 plus 3 plus 2 ist ja 10. Aber leider gibt es hier ein Data Race und zwar dadurch, dass beide parallele Threads versuchen, dieses F erstmal hier zu lesen und anschließend dann auch noch hier zu schreiben, und dass sie sich synchronisieren, haben wir ein Data Race. Und das bedeutet zum Beispiel, dass es passieren kann, dass beide Threads an der Stelle sind, wo sie das alte F lesen, also lesen beide Threads 5, dann addieren beide ihre Werte lokal dazu, der eine kriegt 7, der andere kriegt 8, und schreiben dann beide ihr Ergebnis in das F wieder rein. Und je nachdem, wer zuerst schreibt, wird dann vom zweiten überschrieben, und wir haben als Ende Ergebnis dann schlussendlich nur 7 oder 8, aber eben nicht die erwartete 10. Und um das zu verhindern, muss man sicherstellen, dass dieser Code in add jeweils nur von ein Thread ausgeführt wird, und das macht man zum Beispiel, indem man das Synchronized Key wird hier davor schreibt, und damit sicherstellt, dass immer nur ein Thread pro Zeitpunkt in dieser add Methode drin ist, nämlich der, der zuerst das Log bekommt, was in dem Fall das Foo-Objekt selbst ist. Also, wenn wir Synchronized Foo die Methode schreiben, dann dient das Objekt, auf dem die Methode aufgerufen wird als Log, und wir holen quasi das Log auf dem Foo und rufen dann erst die, gehen dann erst in den Code der add Methode rein. Neben Synchronized hat Java noch zwei weitere Arten und Weisen, wie man Synchronisierung in der Sprache machen kann. Und die zweite Art und Weise ist Wait und Notify. Was ich damit machen kann, ist, dass ich, wenn ich innerhalb einer Critical Section schon bin, kann ich auf eine bestimmte Bedingung warten, und jemand anders darin ist, der gerade in der Critical Section drin ist, kann mir mitteilen, dass diese Bedingung jetzt erfüllt ist. Also, das ist so ein bisschen so ähnlich wie die Conditions, die wir in den Monitors vorhin schon gesehen haben. Das funktioniert so, dass der Thread, der wartet, bis die Bedingung wahr ist, dass innerhalb so einer Schleife machen sollte, wo wir schauen, ob die Bedingung noch nicht wahr ist. Und solange das so ist, rufen wir Wait auf. Und der Thread, der dann Bescheid zeigt, dass die Bedingung jetzt erfüllt ist, der ruft Notify auf. Was macht das konkret? Also, das Wait, das wird das Log, was man aktuell hat, weil das passiert ja in der Critical Section, also in der Code hat schon ein Log wieder abgeben und wartet dann aber, also die Ausführung geht nicht weiter. Und der andere Thread, der Notify aufruft, der sagt allen Threads, die gerade in einem Wait sind und in derselben Critical Section, also mit denselben Log, drin sind, bescheid, dass es bei ihnen jetzt weitergehen kann. Und jetzt können wir mal nachfragen, naja, warum haben wir dieses While-Hierings-Rums? Würde ja eigentlich auch ausreichen, wenn wir einfach nur Wait sagen. Der Grund ist ganz einfach, dass Threads in Java auch aus anderen Gründen aufgewacht werden können oder zum Beispiel nach einem bestimmten, nach einem bestimmten Delay aufgewacht werden können. Und um sicherzustellen, dass das Wait zurückkehrt, weil wir eben aus dem richtigen Grund das Notify bekommen haben, müssen wir hier immer noch mal schauen, ob die Bedingung, um die es eigentlich geht, anschließend dann erfüllt ist. Die dritte Art von Synchronisierung, die es in Java gibt, hat mit dem Java-Memory-Model sehr viel zu tun. Und zwar hatten wir da ja gesehen, dass jeder Thread im Prinzip die Schreibaktion, die er ausführt, in einer bestimmten Reihenfolge ausführen kann oder eben auch später erst scheinbar ausführen kann. Und um das zu verhindern, also um sicherzustellen, dass ein Thread tatsächlich den neusten Wert liest, den der andere Thread geschrieben hat, gibt es im Prinzip mehrere Varianten. Also eine Variante ist, dass wir das Volatile-Keyword verwenden. Das schreiben wir vor ein Feld oder vor eine Variable davor und geben damit an, dass jeder Schreibzugriff auf diese Variable dann sofort für alle anderen sichtbar sein muss. Ist nicht besonders effizient, aber manchmal trotzdem genau das, was man möchte. Und die andere Variante ist, dass nach dem Schreiben der Thread ein Lock abgeben muss, weil das führt auch dazu, dass alles, was im Speicher geschrieben wurde, synchronisiert wird. Das heißt, wenn man in einem Synchronized-Block war oder in einem Synchronized-Method und da dann wieder rausgeht, dann passiert das. Oder wenn man Wait Off ruft, dann passiert das auch. Und dann steht fest, dass andere Threads tatsächlich all das lesen können, was ich gerade geschrieben habe. Und ja, es ist wichtig, dass immer eine von diesen, eines von diesen Konstrukten mindestens verwendet wird, wenn man sicher sein möchte, dass das, was geschrieben wird, tatsächlich dann für alle sichtbar ist. Kommen wir dann einfach nochmal auf das Beispiel von Anfang zurück, was wir jetzt ja schon zum dritten Mal in dem Themenblock hier anschauen. Das war das Beispiel, wo wir dieses Fleck haben, was gleichzeitig von Race Fleck auf True gesetzt wird und was ich hier unten dann ausgelesen würde. Und wir hatten gesehen, dass es dazu doch eher überraschenden Verhalten kommen kann, eben weil man veraltete Werte von diesem Fleck im Main Thread unter Umständen liest. Um das Ganze jetzt zu verhindern, könnte man ganz einfach hier oben Volatile einfügen. Also der einzige Unterschied ist, dass ich vor das Bullier noch Volatile geschrieben habe und wenn wir dieses Feld jetzt Volatile machen, heißt das, das Schreiben, was hier oben geschieht, auf jeden Fall dann auch hier unten sofort sichtbar ist. Das heißt, wenn wir aus dieser Loop rauskommen, sprich wenn da oben geschrieben wurde, werden wir dann hier unten auch noch den selben Wert True wiederlesen und der Code wird dann immer True ausgeben. Also um das Beispiel zu fixen, muss man sozusagen Volatile einfügen. Nachdem jetzt die Synchronisationskonstrukte in Java angeschaut haben, möchte ich noch kurz zwei andere Arten von Synchronisationen in Programmiersprachen erwähnen und zwar zum einen das Transactional Memory. Also hier ist die Idee, dass wir Atomicity garantieren können, also garantieren können, dass eine bestimmte Code Section ausgeführt wird, ohne dass da scheinbar was anderes zwischendurch passiert, ohne dass wir explizit Logs dafür verwenden wollen, also ohne dass der Programmierer entscheiden muss, welches Log jetzt für welche Critical Section zu verwenden ist und zwar soll das so funktionieren, dass man oder funktioniert das so an Transactional Memory, dass man um die Critical Section Atomic rumschreibt, was genau diese Eigenschaft, die ich gerade gesagt habe, garantiert und was die Programmiersprachen Implementierung jetzt hier machen wird, ist, dass jeder solcher Atomic-Blog spekulativ erst mal ausgeführt wird. Also man geht davon aus, dass nichts anderes da dazwischen fungt und keine andere Code gleichzeitig auch in so einem Atomic-Blog drin ist und die dann miteinander interagieren könnten. Und dann, nachdem das ausgeführt wurde, schaut die Sprachimplementierung nach Konflikten. Also sie schaut, ob vielleicht doch gleichzeitig ein anderer Atomic-Blog ausgeführt wurde. Der Daten gelesen oder geschrieben hat die mit den überladen, die in dem ersten Atomic-Blog gelesen oder geschrieben wurden und wo wir dann ein Data-Race hätten. Falls es nicht zu so einem Data-Race gekommen ist, also wenn wir gesehen haben, okay, wir haben das erfolgreich ausgeführt und es ist genau das passiert, was passieren sollte, nämlich, dass das Atomic war, dann wird das Ergebnis committed, also es wird tatsächlich in den Speicher geschrieben und falls es doch zur Laufzeit zu einem Data-Race gekommen sein sollte, dann findet das sogenannte Rollback statt. Also dann wird sozusagen dieses all das, was in dem Atomic-Blog gemacht wurde, wieder rückgängig gemacht und wird nicht in den richtigen Speicher geschrieben und anschließend dann nochmal probiert, wo wir dann hoffen, dass dann parallel nichts anderes dazwischen funktioniert. Ist also sozusagen relativ elegante Art und Weise, Atomic Sections aufzuschreiben, weil man sich nicht wirklich drum kümmern muss, welche Logs wo verwendet werden müssen, sondern die Sprachimplementierung schaut mehr oder weniger automatisch danach, dass es keine Data-Races gibt. Die letzte Form von Synchronisierung ist aus Programmierersicht noch bequemer, weil man im Prinzip gar nichts mehr machen muss, nämlich geht es hier um implizite Synchronisierung, bei denen sich der Compiler anschaut, welche Abhängigkeiten zwischen potenziell parallel ausgeführten Kotstücken existieren und dann automatisch die passende Synchronisation einfügt, wo auch immer dieser Synchronisation tatsächlich verwendet wird. Das heißt, als Programmierer kann ich in diesem Szenario meinen Kot im Prinzip so schreiben, als gäbe es nur ein Threat und der Compiler erkennt dann automatisch, welche Teile nicht voneinander abhängen und deswegen parallelisiert werden könnten. In der Praxis gibt es Ansätze, die das probieren, aber es ist leider sehr, sehr schwierig. Also es gibt ein paar Compiler und auch Sprachen, in denen das tatsächlich passiert, aber so richtig, richtig gut funktioniert das nicht und zwar aus dem Grund, dass es sehr schwierig ist, statisch also zur Compile-Zeit darüber nachzudenken, welche Kotstücken denn jetzt wirklich unabhängig voneinander sind, weil man einfach ohne die Kot auszuführen, vieles nicht genau entscheiden kann und was dieser automatisch parallelisierenden Compiler dann also machen müssen, ist immer vom schlimmsten möglichen Fall auszugehen. Das heißt, sie können viel Parallelität, die ein Programmierer nutzen, würde nicht wirklich nutzen und der Kot ist am Ende einfach nicht so effizient, wie wenn das jemand wirklich per Hand machen würde. In bestimmten Domain funktioniert das manchmal ganz gut, aber im Allgemeinen ist das im Prinzip noch nicht wirklich machbar, sondern ist noch eine offene Forschungsfrage, wie man es genau umsetzen kann. Zum Abschluss habe ich noch ein kleines Quiz und zwar wieder in der Form von vier Sätzen, von denen manche stimmen und manche nicht stimmen und wie immer würde ich Sie bitten, das Video an der Stelle kurz anzuhalten, die Sätze durchzulesen, im Illers abzustimmen, welche Sätze denn hier korrekt sind und dann erst die Lösung anzuschauen. Schauen wir mal an, was hier stimmt. Das meiste stimmt nicht, sondern nur der erste Satz ist richtig und zwar der, der sagt, dass Barrier eine Art von Busy-Wade-Synchronisation sind, genau das sind Barriers. Die anderen Sätze stimmen leider alle nicht, also ein Memory-Model spezifiziert nicht, dass eine Programmiersprache immer sequentially consistent sein muss, sondern sie spezifiziert, welche Abhängigkeiten eingehalten werden von dem, was im Code steht und an welche Stellen die Programmiersprache sich aber auch erlaubt, Dinge eben in der anderen Reihenfolge auszuführen, als das, was jetzt wirklich im Programmcode steht und auch welche Speicherzugriffe vielleicht nicht sofort sichtbar sind. Sequentially consistent sie wird sozusagen das Maximum, was Memory-Model spezifizieren könnte in der Praxis, machen die das aber in der Regel nicht, sondern erlauben dem Compiler da mehr, mehr Freiheit in der Optimierung des Codes. Der dritte Satz stimmt auch nicht, also die Dinge, die Signale aussenden und dann auch wieder von anderen Threats empfangen, können, waren die Operationen in Monitoren, aber nicht die conditional critical regions und dann der letzte Satz stimmt auch nicht, also nicht jede Schreiboperation in ein Feld in Java ist sofort für andere Threats sichtbar, sondern man muss das eben durch explizite Synchronisation mit Volatile oder Synchronized oder Weight forcieren, ansonsten sieht ein Threat eben nicht immer den neuesten Wert. Ja, und dann sind wir am Ende von den vier Videos zum Thema Concurrency. Ich hoffe, Sie wissen jetzt ein bisschen besser, welche Konstrukte es in Programmiersprachen gibt, um Nebenläufigkeit und Parallelität auszudrücken und auch was man dabei beachten sollte, um insbesondere durch Synchronisation eben das Verhalten zu bekommen, was man als Programmierer auch gerne hätte. Damit vielen Dank fürs Zuhören und bis zum nächsten Mal.