 Ja, herzlich willkommen zurück zu Programmierparadigmen. Wir sind hier in Teil 3 von 4 des Themen Blocks Concurrency. Und worum es hier jetzt gehen soll, ist wie genau Programmiersprachen eigentlich implementieren, dass sich parallel ausführende Threads miteinander synchronisieren können. Und wir werden gewisse Konstrukte sehen, die dafür üblicherweise verwendet werden. Fangen wir mal an mit der Frage, warum wir Synchronisierung überhaupt brauchen bzw. was das Ziel von Synchronisierung in Programmiersprachen ist. Es gibt da im Grunde zwei Ziele und zwar ist das erste die Atomarität. Also wir möchten manchmal, dass eine bestimmte Operation Atomar erscheint, was so viel bedeutet, dass die Instruktion, die innerhalb dieser Operation stattfinden, also zum Beispiel mehrere Statements, die ich hintereinander in meinem Code habe, aus Sicht von anderen Threads so aussehen, als ob sie wirklich gemeinsam nacheinander ausgeführt werden, ohne dass da ein anderer Thread irgendwas dazwischen macht. Eine besondere Form von diesem Ziel von dieser Atomarität sind die Mutually Exclusive Blocks, also Blocks, die sicherstellen, dass ein bestimmtes Stück Code immer nur von einem Thread gleichzeitig ausgeführt wird. Also dass wir in diesem Stück Code niemals zwei Threads haben, die das Stück Code parallel ausführen. Und dieses Stück Code, das nennt sich Critical Section, also wir haben quasi ein Stück Code, was eben nur von einem Thread zu einem bestimmten Zeitpunkt ausgeführt werden darf. Das zweite Ziel, was man mit Synchronisierung verfolgt und was so ein bisschen orthogonal zu dem Ersten ist, ist, dass wir auf eine bestimmte Bedingung schauen wollen und eben erst in der Ausführung fortfahren wollen, wenn diese Bedingung gilt. Denn ansonsten hätte das Programm nicht die Semantik, die wir gerne wollen, sondern es muss sichergestellt sein, dass diese Bedingung, zum Beispiel, irgendeine Berechnung ist abgeschlossen, gilt bevor wir anschließend dann weitergehen. Ein Grundproblem, was man in paralleler Programmierung immer hat, ist die richtige Balance zu finden zwischen Synchronisierung und Parallelität. Also auf der einen Seite braucht man natürlich Synchronisierung, um sicherzustellen, dass die Berechnung korrekt ist, denn an bestimmten Stellen müssen die parallelen Ausführungsstränge einfach miteinander synchronisiert werden, sonst kommt einfach nicht das raus, was man möchte. Auf der anderen Seite führt jede Form von Synchronisierung, aber natürlich dazu, dass die maximale Parallelität, die das Programm vielleicht anderenfalls hätte, nicht mehr ausgenutzt werden kann. Also so als ein extremen Beispiel, ich könnte ein paralleles Programm schreiben, in dem ich ein globales Lock habe, was dann immer zuerst genommen werden muss, um überhaupt irgendeine Berechnung ausführen zu können. Das Programm wäre dann im gewissen Sinne schon parallel und hätte mehrere Threads, aber de facto führt dann doch immer nur ein Thread zu einem bestimmten Zeitpunkt etwas aus, und ich hätte überhaupt keine Parallelität. Da hätte ich also sozusagen zu viele Synchronisierung. Das andere extremen Beispiel ist, ich habe ein Programm mit vielen parallelen Threads und habe überhaupt keine Form von Synchronisierung. Das heißt, die Threads können einfach immer weiter rechnen, dann hätte ich maximale Parallelität, aber mit großer Wahrscheinlichkeit nicht mein Ziel erreicht, weil quasi jeder eigene Rhythmus irgendwo mal eine bestimmte Form von Synchronisierung braucht, um tatsächlich dann das Ergebnis zu berechnen, was man berechnen möchte. In den nächsten paar Minuten wollen wir uns jetzt mal eine Form von Synchronisierung, nämlich die Busy Weight Synchronisierung, ein bisschen genauer anschauen. Also nochmal zur Erinnerung, Busy Weight heißt einfach nur, dass ein Thread aktiv wartet, bis eine bestimmte Bedingung wahr ist, indem er immer wieder schaut, ist diese Bedingung jetzt wahr und erst dann die Ausführung weiter fortführt. Und wir werden uns zwei Mechanismen anschauen, um solche Busy Weight Synchronisierung zu implementieren, nämlich zum einen die Spinlocks, mit denen man Mutual Exclusion erreichen kann, also dass nur ein Thread pro Zeit in einem bestimmten Codeblock ist und dann die sogenannten Barriers, bei denen alle Threads aufeinander warten und erst dann weitermachen, wenn wirklich jeder Thread an einem bestimmten Punkt eben dieser Barrier angekommen ist. Schauen wir uns zunächst erstmal die Spinlocks an. Spinlock ist eine bestimmte Form von Lock und was ich damit erreichen will ist Mutual Exclusion, was also bedeutet, dass nur das Stück Code oder nur der Ausführungsstrang, nur der Thread, der das Lock hat, soll ein bestimmtes Stück Code ausführen können. Im Prinzip kann ich solche Spinlocks nur mithilfe von Load und Store-Operationen implementieren. Das ist aber eher so eine theoretische Übung, denn dafür würde man sehr viel Zeit und sehr viel Speicher auch benötigen, mehr als man in der Praxis tatsächlich verwenden möchte. Und was stattdessen praktisch gemacht wird, ist, dass auf quasi allen Hardware-Achtiktonen, die es heutzutage so gibt, spezielle Instruktionen gibt, mithilfe der man in einem atomaren Schritt ein Stück Speicher lesen, verändern und dann auch wieder schreiben kann. Und diese speziellen Instruktionen kann man dann verwenden, um Spinlocks zu implementieren. Eine solche spezielle Hardware-Instruktion ist das sogenannte Test-N-Z und das schauen wir uns jetzt mal ein bisschen genauer an. Also was Test-N-Z macht, ist im Prinzip das, was der Name auch schon suggeriert, ist eine Instruktion, die setzt eine Variable und testet auch gleichzeitig noch, welchen Wert die Variable vorher hatte. Und ein bisschen genauer gesagt, setzt Test-N-Z eine bulche Variable immer auf den Wert True und gibt uns gleichzeitig aber noch zurück, ob diese Variable vorher false war. Und wenn wir diese Test-N-Z-Instruktion in unsere Hardware zu verfügen haben, können wir damit relativ einfach ein Spinlock implementieren und zwar so, wie man das hier sieht, also was wir da machen ist, wir haben im Prinzip eine einzelne Schleife, die immer wieder Test-N-Z auf eine Variable L aufruft, L repräsentiert hier das Lock und wenn dieses Test-N-Z uns jetzt True zurück gibt, dann würden wir erst der Schleife rausgehen und es gibt uns True zurück, wenn diese Variable L den Wert false hat. Das heißt, wenn dieses Lock tatsächlich zur Verfügung ist. Also L gleich false heißt, das Lock ist zur Verfügung, L True heißt, das Lock hat gerade jemand anders und wir testen sozusagen immer wieder, ob die Variable jetzt endlich false ist und setzen dann das Lock aber auch gleich, oder die Variable L auf True, um einzugeben, dass wir das Lock jetzt haben wollen. Und wenn wir dann aus dieser Schleife raus sind, dann kann der Code hier in seine Critical Section reingehen und ist sicher, dass niemand anders gleichzeitig dieses Lock bekommen kann. Schauen wir uns dieses Test-N-Z mal anhand eines einfachen Beispiels an. Also in dem Beispiel gehen wir davon aus, dass wir zwei Threads haben, T1 und T2 und wir haben irgendwo diese Variable L, die das Spinlock repräsentiert und wenn L auf False gesetzt ist, bedeutet, dass das Lock niemand hat und wenn man das auf True setzt, dann nimmt man sich sozusagen das Lock. Gehen wir jetzt mal davon aus, dass irgendwann in der Ausführung T1 das Lock haben will. Also ruft T1 Test-N-Z auf und zwar Test-N-Z von L, weil L aktuell ja noch false ist. Jetzt werden wir aus dieser Wildschleife, die wir gerade eben gesehen haben, rauskommen und setzen L dann aber anschließend auf True. Und dann kann T1 weitermachen und sozusagen die Critical Section, die es hat, ausführen und ist sicher, dass kein anderer Thread, insbesondere nicht T2, in dieser Zeit in die Critical Section reingehen werden. Und an irgendeine Stelle gibt T1 das Lock dann wieder ab und zwar indem es L wieder auf false setzt. So, was passiert jetzt, wenn wir in der Zwischenzeit Test-N-Z in Thread 2 aufrufen, sagen wir mal hier, dann wird Thread 2 das L auf True setzen, True war es auch schon vorher und bekommt aber zurück, dass es vorher nicht false war. Das heißt, wir bleiben in dieser Spinlock-Schleife drin und werden das dann weiterprobieren, aber immer wieder nur sehen L ist immer noch True und das geht so lange, bis wir dann irgendwann hinter diesem Punkt sind, wo L auf false gesetzt wurde und wenn wir dann hier sind, wird L dann tatsächlich false sein und wir bekommen das in Thread 2 gesagt, gehen damit aus der Spinlock-Schleife raus und anschließend ist L dann wieder auf True gesetzt, denn das macht ja die Test-N-Z Instruktion für uns. Ein Problem, was diese relativ naive Art Test-N-Z zu benutzen jetzt hat, ist, dass wir immer wieder in diese Variable L reinschreiben und immer wieder den Wert True reinschreiben, obwohl wir eigentlich nur wissen wollen, ob der Wert immer noch True ist oder mittlerweile wieder auf false gesetzt wurde. Und das führt in der Praxis zu relativ schlechter Effizienz, denn dieses ständige Reinschreiben führt zur sogenannten Contention, was einfach nur bedeutet, dass sehr viel auf eine bestimmte Speicherstelle geschrieben wird und dieses so implementierte Spinlock dann nicht besonders effizient wäre. Um das zu vermeiden, um diese Contention zu vermeiden, wird die sogenannte Test-N-Test-N-Z-Strategie verwendet, was im Prinzip einfach nur in etwas kompliziertere Art und Weise dieses Test-N-Z aufzurufen ist. Wie das Ganze aussieht, sieht man hier unten. Also wir haben wieder diese Variable L, die das Lock repräsentiert und false bedeutet wieder, dass das Lock zur Verfügung steht und True heißt, jemand anders hat es gerade und dann haben wir hier diese zwei Prozeduren, um das Lock zu nehmen, also Acquire Lock und dann wieder freizugeben, also Release Lock. Und was wir hier machen ist, dass wir, wenn wir das Lock haben wollen, wieder diese Schleife haben, die schaut, also die Test-N-Z aufruft und nur wenn das nicht mehr wahr zurückgibt, gehen wir aus der Schleife raus und innerhalb dieser Schleife haben wir aber dann aber noch eine Schleife, also das ist jetzt das Neue, in der wir einfach nur die Variable L lesen und nicht jedes Mal Test-N-Z aufrufen, sondern einfach nur lesen und so lange warten, bis wir da einmal den Wert false lesen und sobald wir den Wert false lesen, kommen wir dann wieder zurück zur äußeren Schleife, checken dann nochmal mit Test-N-Z, ob wir jetzt wirklich den Wert false bekommen und setzen den dann gleichzeitig auch auf True, das heißt, wir sind dann sicher, wenn wir hier aus der äußeren Schleife rauskommen, dass wir das Lock jetzt haben und L auf True gesetzt ist und aber niemand anders aus dieser Schleife rauskommt und ja, das Lock wieder freigem ist das Gleiche wie gerade eben schon, nämlich einfach das Lock dann wieder auf Fonds zu setzen. Der große Vorteil dieser Test-N-Test-N-Z-Implementierung ist, dass wenn jemand anders dieses Lock jetzt gerade schon hat, dann lesen wir zwar wiederholt das Lock L, aber wir müssen nicht jedes Mal einen neuen Wert oder den Wert True reinschreiben und dieses Lesen ist dank dem Caching, was die CPUs für uns machen, sehr effizient, also das kostet weniger als die naive Implementierung, die wir gerade gesehen haben. Jetzt haben wir eine Form von Busy-Wade-Zonclonization, nämlich die Spin-Locks gesehen und wollen uns jetzt noch eine zweite Form anschauen, nämlich die sogenannten Barriers. Was Barriers machen wollen, ist sicherstellen, dass alle Threads zunächst eine Phase der Ausführung fertigstellen, bevor irgendein Thread in die nächste Phase der Ausführung weitergehen kann. Also wir haben diesen einen Punkt, den alle Threads erst erreichen müssen, bevor es für irgendein weitergehen darf. Und dieser Punkt, der heißt eben Barrier. Um so eine Barrier zu implementieren, benutzen wir eigentlich wieder den selben Trick, den wir gerade eben schon gesehen haben, nämlich eine bestimmte Hardware-Instruktion, die nicht nur ein Lese- oder Schreibvorgang macht, sondern zwei Sachen zusammenatomar für uns ausführt. Und diese atomare Hardware-Instruktion, die hier verwendet wird, nennt sich Fetch and Decrement. Und was die im Grunde macht, ist, die holt den aktuellen Wert einer Variable, zum Beispiel einer Intit, oder ja nicht zum Beispiel immer einer integer Variable, gibt uns diesen aktuellen Wert zurück und decrementiert anschließend die Variable, also zieht eins davon ab. Wenn man jetzt so eine Fetch and Decrement-Instruktion hat, dann kann man damit Barriers wie folgt implementieren. Also wir benutzen ein Shared-Counter, also eine integer Variable, die von allen Threads verwendet werden kann und initialisieren diesen Counter mit n, wobei n die Anzahl der Threads es dies insgesamt gibt. Wenn jetzt der aktuelle Thread an diese Barrier ankommt, also selbst an der Stelle ist, wo alle hin sollen, dann ruft er dieses Fetch and Decrement auf. Das heißt, er reduziert den aktuellen Wert des Shared-Counters um 1 und schaut dann nach, ob der Wert, den der Shared-Counter hatte, vorher 1 war, also sprich, ob er der letzte war. Und wenn der Thread merkt, oh, ich bin der letzte, der jetzt hier angekommen ist, dann setzt er eine geteilte Bulge-Variable auf den Wert, den es vorher nicht hatte, also flippt diese Bulge-Variable und das ist das Zeichen für alle anderen Threads, dass es jetzt weitergehen kann und die Barrier erreicht wurde. Schauen wir uns das Ganze mal mit so ein bisschen Pseudocode an, der im Prinzip das zeigt, was sich gerade schon mal so grob erklärt hat. Also wir haben jetzt zum einen diese Variable n, die einfach dieser Shared-Counter ist und initialisiert wird mit der Anzahl der Threads, die die Barrier erreichen müssen. Dann haben wir diese Variable sense und das ist dieses von allen Threads geteilte Fleck mithilfe dessen, die Threads feststellen können, ob die Barrier jetzt von allen erreicht wurde oder nicht. Und gleichzeitig haben wir noch dieses Local Sense, das ist eine lokale Variable, die jeweils nur der eigene Thread lesen kann und die Semantik von diesen beiden Variables in Local Sense und Sense ist so, dass wenn diese beiden Flecks den gleichen Wert haben, dann heißt es, wir sind an der Barrier und es kann weitergehen. Und der Grund, warum man diese zwei Variablen hat, ist ganz einfach, dass man zwischen True und False hin und her flippen kann. Also es ist nicht so, dass der Wert True immer bedeutet, dass es weitergeht oder der Wert False immer bedeutet, dass es weitergeht, sondern wenn beide Variable Sense und Local Sense den selben Wert haben, dann haben wir die Barrier erreicht und es kann weitergehen. So, was machen wir jetzt in diesem Code hier unten? Also diese Procedure Name its Barrier wird immer dann aufgerufen, wenn ein Thread an der Stelle ist, wo die Barrier ist und der Thread sozusagen angibt, ich bin jetzt da. Und was wir dann hier machen ist, zunächst erstmal Local Sense zu flippen, denn das, weil wir jetzt zum ersten Mal an der Barrier ankommen, dann wird Fetch and Decrement aufgerufen und geschaut, ob das gleiche eins ist, gleiche eins bedeutet, oh, ich bin der letzte Thread, der jetzt hier ankommt. Wenn das der Fall ist, dann initialisiert dieser letzte Thread, die Barrier schon für die nächste Iteration, also diese Barriers können wiederverwendet werden, in dem Count wieder auf N gesetzt wird und setzt auch Sense dann auf den selben Wert wie Local Sense, sodass die beiden den gleichen Wert haben und sagt somit den anderen Threads, dass es jetzt weitergehen kann. Wenn ich nicht der letzte Thread bin, der hier ankommt, dann befinden wir uns hier unten, wo dann einfach nur wiederholt gewartet wird, bis diese Variable Sense, also die geteilte Variable den gleichen Wert hat wie die Variable Local Sense, also bis ein anderer Thread sozusagen Sense auf den Wert gesetzt hat, der mir sagt, dass es jetzt weitergehen kann. So, um das Verständnis von diesen Barriers ein bisschen zu überprüfen, haben wir jetzt ein kleines Quiz und zwar ist das ein Quiz, in dem wir etwas Java Code haben, der eine Implementation dieser Barriers in Java benutzt, nämlich in der Klasse Cyclic Barrier und die Frage für Sie ist, was kann dieser Code denn alles ausgeben, also was sind die möglichen Ausgaben, die dieser Code hat. Ich würde Sie bitten, das in Ruhe anzuschauen, anschließend dem Ilias abzustimmen und dann weiterzuschauen. Schauen wir uns die Lösung mal an, also im Gegensatz zu den anderen Beispielen, die wir bis jetzt gesehen haben, gibt es hier eben nur eine mögliche Ausgabe, denn dank dieser Barriers ist klar definiert, was hier passieren kann und diese mögliche Ausgabe ist A, A, A, A, B, B, B, also wo sozusagen erst alle A's ausgegeben werden und dann alle B's ausgegeben werden. Warum ist das so? Schauen wir mal an, was wir hier machen. Also hier oben haben wir eine Runnable Klasse, die eben diese Run Methode hat und was dann hier passiert ist sozusagen der Code, der in einem parallelen Thread oder in mehreren parallelen Threads ausgeführt wird. Hier unten erstellen wir in der Schleife dann vier dieser Threads und starten die auch jeweils gleich. Und außerdem haben wir diese Cyclic Barrier, die auch hier die Zahl 4 bekommt, also die sozusagen auf 4 Threads wartet, die diese Barrier jeweils erreichen, bevor es weitergehen kann. Und hier oben sehen wir dann zwei Aufrufe dieser Barrier einmal hier und dann einmal hier, wo wir jeweils await aufrufen und await wartet sozusagen bis alle Threads, alle 4 Threads oder mindestens 4 Threads in dem Fall an diesen Punkt ankommen und erst dann geht es weiter. Das heißt, alle 4 Threads werden dieses erste Print Line ausführen und A schreiben, dann batten alle bis das wirklich überall passiert ist und dann erst geben alle 4 dieses B aus, batten wieder und dadurch ist sicher, dass immer 4 mal A und dann 4 mal B rauskommt. Die Synchronisationskonstrukte und wie sie implementiert werden und die wir uns bis jetzt angeschaut haben, treten sich alle um die Frage, in welche Reihenfolge bestimmte Sachen ausgeführt werden und wie viele Threads gleichzeitig ein bestimmtes Stück Code ausführen können. Was wir jetzt machen wollen, ist uns noch die Synchronisierung von den eigentlichen Daten anzuschauen, und zwar indem wir uns Memory Consistency ein bisschen genauer anschauen. Also die Frage hier ist im Prinzip, wenn es Speicherstellen gibt, die parallel geschrieben werden, dann, wann werden diese Schreibaktionen denn dann überhaupt für die anderen Threads sichtbar? Also wenn ein Thread was schreibt und vielleicht zwei davon auch noch was schreibt und ein dritter liest diese Werte dann, welche Werte liest der und wann kann der die geschriebenen Werte überhaupt konkret lesen? Was die meisten Programmierer, wenn man sich vielleicht noch nicht besonders viel mit dem Thema beschäftigt, hat intuitiv annehmen, ist, dass sogenannte Sequential Consistency gilt. Das ist so das, was man intuitiv vielleicht erwarten würde. Und das bedeutet, dass die Instruktionen in einem Thread immer genau in der Reihenfolge ausgeführt werden, in der sie tatsächlich im Code stehen. Und das Shared Memory sich so verhält wie ein großes globales Array. Also das sozusagen alle Lese- und Schreiboperationen, die auf diesem Shared Memory stattfinden, jeweils sofort passieren. Das heißt, wenn ein Thread irgendwo was hinschreibt und der andere anschließend was liest, dann liest er natürlich diesen aktuellen Wert. Das wäre Sequential Consistency und das ist das, was die meisten Programmierer, glaube ich, annehmen, wenn sie sich mit dem Thema noch nicht groß beschäftigt haben. In der Praxis ist das aber nicht so, sondern was in der Praxis verwendet wird, sind sogenannte Relaxed Memory Models. Also in den meisten Sprachen und auf quasi aller Hardware ist eben nicht Sequential Consistency, sondern wir haben sogenannte Relaxed Memory Models. Und was das bedeutet, ist, dass manche der Lese- und Schreiboperationen, die so ein Programm ausführt, in der falschen Reihenfolge stattfinden, also out of order. Falsche Reihenfolge heißt hier falsch bezüglich dessen, was man naiverweise vielleicht erwarten würde und insbesondere auch anders, als dass das vielleicht im Programm Text drinsteht. Der Grund hierfür ist ganz einfach Effizienz. Also um diese Sequential Consistency zu gewährleisten, die man vielleicht erwarten würde, wäre sehr, sehr viel Synchronisierung nötig. Und was stattdessen von der Hardware und auch von Compiler gemacht wird, ist, dass bestimmte Instruktionen manchmal in der anderen Reihenfolge ausgeführt werden oder ein bisschen verzögert werden. Zum Beispiel, weil das Lesen von einem Wert lange dauert und man in der Zwischenzeit vielleicht schon bestimmte Berechnungen vorziehen kann. Das ist, wenn wir jetzt nur ein Thread und keine Parallelität haben, auch überhaupt kein Problem. Wenn wir allerdings Parallelität haben, führt das zu manchmal ein bisschen überraschenden Verhalten, was wir uns auch gleich noch ein bisschen genauer anschauen wollten. Noch mal als Beispiel für die Ineffizienz, die man hätte, wenn man die Sequential Consistency in parallelen Programmen garantieren müsste. Schauen wir uns mal an, wenn eine Variable geschrieben wird, die aktuell aber nicht im CPU Cash ist. Das heißt, wenn das geschrieben werden muss, dann muss der Prozessor sozusagen bis zum Hauptspeicher gehen und die Variable tatsächlich da reinschreiben. Und das braucht ja einige hundert Zyklen, um wirklich fertig zu sein. Und um eben nicht diese ganzen Zyklen einfach nur da zu sitzen und zu warten, macht das der Prozessor in der Regel so im Hintergrund. Also dieses Schreiben passiert irgendwann, aber eben nicht sofort. Und wenn auf demselben CPU Core diese Variable jetzt wieder gewesen wird, dann sieht man auf diesem selben CPU Core den aktuellen Wert über den sogenannten Ride Buffer, in dem eben drinstehen, welche Werte jetzt schon geschrieben sind, aber eben noch nicht wirklich im Hauptspeicher angekommen sind. Solange das alles auf einem CPU Core oder in einem Thread nur passiert, ist alles gut. Aber wenn jetzt noch ein anderer Threadster ist, dann sieht ja diesen tatsächlich geschriebenen neuen Wert eben erst nachdem diese hunderte von CPU Cycles Zyklen tatsächlich passiert sind. Und in der Zwischenzeit können vielleicht schon andere Instruktionen auf diesem anderen Core ausgeführt werden, wo man der Evoweise vielleicht davon ausgehen würde, dass dieser Wert tatsächlich schon geschrieben wurde, aber eben dann noch nicht geschrieben ist. Schauen wir zur Verdeutigung nochmal ein Beispiel an, wo wir sehen, welchen Effekt die relaxed memory Models haben können. Und zwar gehen wir davon aus, dass wir hier zwei Variablen haben, nämlich einmal eine Variable namens inspected, die wir initial auf false setzen und dann eine Variable namens x, die wir initial auf null setzen. Und dann haben wir zwei Threads, die auf unterschiedlichen CPU Cores ausgeführt werden, Core A und B. Und die führen folgende Instruktion aus, also der Core der auf Core A ist, setzt inspected of true und liest den aktuellen Wert von x in eine Variable xA, die ja lokal für Core A ist. Und auf Core B schreiben wir x gleich eins und lesen außerdem den Wert von inspected in eine Variable namens ib. So, jetzt die Frage, in welche Reihenfolge können diese vier Instruktionen, die wir hier haben, denn jetzt tatsächlich ausgeführt werden. Ich nenne hier einfach mal 1, 2, 3, 4. Und die Antwort hängt jetzt davon ab, ob wir eben sequential consistent sie haben oder ein relaxed memory model. Schauen wir erstmal den Fall von sequential consistent sie an. Also wenn wir hier die Reihenfolge, der tatsächlich ausgeführte Instruktionen anschauen und zwar unter der Annahme, dass wir sequential consistent sie haben, dann gibt es da mehrere Möglichkeiten und für jede dieser Möglichkeiten schreibe ich auch gleich mal noch auf, was dann die Werte sind, die in xA und in ib schlussendlich drin sind und zwar geht es jetzt wie folgt. Also eine Variante wäre, dass zunächst erstmal alles von Core A ausgeführt wird und anschließend der Code von Core B in dem Fall hätten wir am Ende in xA0 drin stehen, weil ja in Instruktion 2 der initiale Werte von x noch gelesen wird und in ib hätten wir zu stehen. Dann gibt es noch andere Varianten, im Prinzip alle Permutationen von diesen vier Instruktionen. Ich schreibe die jetzt einfach schon mal alle hier rein und Sie können das dann gerne mal selber nachprüfen, ob das alles so stimmt. Also jede, da keine Synchronisierung oder keine Logs und so weiter hier in dem Code sind, ist wirklich jede Reihenfolge möglich. Das heißt, wir könnten auch mit den Instruktionen von Core A anfangen, aber das Einste, was jetzt sichergestellt ist, in sequential consistent sie, ist, dass die Reihenfolge innerhalb der Chorus sich nicht ändert. Also ich habe nie 2 vor 1 und ich habe nie 3 vor 4. Die letzte Variante sind die alle schön ineinander verschacht wird und für jede diese Varianten können wir uns jetzt anschauen, welchen Wert xA und ib am Ende dann haben und ohne dass ich durch die jetzt alle Einzelnen durchgehe, das können Sie gerne einfach mal selber machen, ein Video kurz anhalten und schauen, ob Sie das nachvollziehen können. Kommen wir schlussendlich darauf, dass in den Varianten, die hier aufgelistet sind, entweder immer 0 und True oder 1 und True oder 1 und False rauskommt, wenn wir denn sequential consistent sie haben. Wenn wir jetzt aber eben nicht sequential consistent sie haben, sondern ein relaxed Memory Model verwenden, also quasi das, was in der Praxis auf quasi allen Computern so passiert, dann kommen letztlich Sachen raus, die man so vielleicht nicht erwartet, denn unter anderem könnte es jetzt passieren, dass eben xA gleich 0 ist und gleichzeitig am Ende ib den Wert False hat, also quasi eine Kombination von Werten, die wir mit sequential consistent sie eben nie bekommen würden und der Grund ist ganz einfach, dass diese beiden Cores hier unter Umständen alte Werte lesen können. Also wenn das Schreiben von 1 in x in Instruktion 3 sehr lange dauert und oder das Schreiben von True in inspected Instruktion 1 sehr lange dauert, dann kann es sein, dass die Cores jeweils den alten Wert lesen und damit dann eben schlussendlich, nachdem diese vier Instruktionen ausgeführt wurden, auf diese doch eher unerwartete Kombination von Werten kommen können. Was wir da jetzt gesehen haben, ist das, was auf der Hardware tatsächlich stattfindet, also in die Reihenfolge, in der die Instruktion tatsächlich stattfinden. Wenn wir jetzt direkt in diesen Hardware Instruktion programmieren würden, also im Prinzip Assembler schreiben würden, dann müssten wir uns damit befassen, wie das auf verschiedenen Hardware-Achtikton tatsächlich umgesetzt ist, weil es kann sein, dass auf einem Laptop das so ist und auf einem anderen Laptop vielleicht so, und wir müssten jedes Mal wissen, ob diese scheinbare Reihenfolgenveränderung der Instruktion stattfindet oder wann es möglich ist, dass wir so veraltete Werte lesen. Was Programmiersprachen jetzt immer machen wollen, ist, dass sie natürlich unabhängig von der Hardware überall genau dasselbe Verhalten garantieren wollen und die Art und Weise, wie Programmiersprachen das machen, ist, indem sie ihr eigenes Memory-Model definieren und auch implementieren, was definiert, unter welchen Umständen Instruktionen scheinbar in der falschen Reihenfolge ausgeführt werden können und unter welchen Umständen scheinbar zu alte Werte tatsächlich gelesen werden können. Und die Programmiersprache definiert es dann sozusagen einmal für diese Sprache und die Implementierung der Sprache, also der Compiler, muss sich dann damit umschlagen, wie das Ganze tatsächlich auf die Hardware abgebildet wird. Es gibt viele solcher Memory-Models, die wahrscheinlich bekanntesten ist das Java Memory-Model und das C11 Memory-Model, die das Ganze eben einfach für Java und C definieren. Was die Programmiersprache Implementierung dann macht, um sicherzustellen, dass an bestimmten Stellen die Speicherzugriffe dann doch synchronisiert werden und man eben garantiert den letzten und neuesten Wert einer Variable liest, ist das sogenannte Fences eingefügt werden. Also bestimmte Instruktion, die eben sagen, dass an der Stelle jetzt die Speicherzugriffe synchronisiert werden müssen. Die kosten natürlich was, weil dann unter Umständen einige Zyklen gewattet werden muss, bis tatsächlich die Werte an der Stelle sind, wo sie dann benötigt werden. Aber das ist dann eben nötig, um sicherzustellen, dass tatsächlich die Werte gelesen werden, die man erwartet. Also ein Beispiel für so ein Memory-Model wollen wir uns hier mal kurz das Java-Memory-Model anschauen, weil das doch Dinge definiert, die vielleicht nicht jedem Java-Programmierer gleich so bewusst sind. Also was das Java-Memory-Model sagt, ist, dass Schreiboperation auf geteilten Objekten nicht unmittelbar für alle anderen Threads sichtbar sein müssen. Das heißt, ein Thread, der einen Wert liest, der von einem anderen Thread geschrieben werden kann, kann unter Umständen einen veralteten Wert bekommen. Und es gibt erstmal keine Garantie, die sagt, dass man jetzt hier den neuesten Wert immer liest. Wenn man als Programmierer sicherstellen möchte, dass tatsächlich immer der neuste Wert gelesen wird, dann muss man das tun, indem explizit Synchronisations-Konstrukte verwendet werden. Wir werden die gleich noch ein bisschen genauer dann im 4. Video sehen, aber die Grundidee ist, dass wir 2 dieser Konstrukte in Java haben. Nämlich zum einen können wir Felder als Volatile markieren. Was bedeutet, dass jeder Wert, der da reingeschrieben wird, immer als der neuste Wert dann auch für die anderen Threads sichtbar sein müssen. Oder wir können synchronized Blocks haben oder synchronized Methoden haben, die implizit auch eine Synchronisierung des Speichers erzeugen. Aber wenn wir das eben alles nicht haben, also wenn wir keine dieser expliziten Synchronisations-Konstrukte verwenden, dann können andere Threads unter Umständen alte Werte lesen. Und als Java-Programmierer, der irgendwo mal parallel programmiert, sollte man das unbedingt wissen, weil es sonst nämlich zu überraschenden Verhalten gucken kann. Zur Illustration schauen wir uns noch mal 2 Beispiele an. Das 1. ist das Beispiel, was wir in dem 1. Video in diesem Concurrence-Item-Block schon gesehen haben und was jetzt hoffentlich ein bisschen klarer wird. Und das 2. Beispiel ist dann eine neueste Code, was Sie als Quiz bekommen. Jetzt fangen wir mit dem 1. Beispiel an. Das ist das, was Sie ganz am Anfang schon mal gesehen haben. Wir hatten da also dieses Flag, was schert ist zwischen den verschiedenen Threads und der Code, der hier parallel ausgeführt wird, nämlich diese Methode Race Flag, den setzt dieses Flag auf True. Und gleichzeitig haben wir in dem Hauptthread, der also parallel zu Race Flag läuft, diesen Code, der den aktuellen Wert von Flag liest oder zumindest den scheinbar aktuellen Wert von einen Wert den Flag mal hatte liest und dann einen Wert den Flag mal hatte auch wieder ausgibt. Und ich sage jetzt eben einen Wert den Flag mal hatte, weil es hier kein Volatile und kein Synchronized gibt. Das heißt, diese beiden Threads, der Hauptthread und der Thread, der Race Flag ausführt, laufen parallel und synchronisieren sich nicht bezüglich dieses Feldes Flag. Das heißt, der Hauptthread liest unter Umständen auch den alten Wert False, selbst wenn der zweite Thread da irgendwann schon mal True reingeschrieben hat. Und das kann dazu führen, dass der Code entweder hier hängen bleibt, weil wir einfach immer False lesen und nie sehen, dass das Flag auf True gesetzt wurde. Oder wir kommen über die Schleife irgendwann hinweg und lesen dann aber trotzdem noch mal den alten Wert False, obwohl wir den neueren Wert vorher auch schon gelesen haben. Auch das ist entsprechend des Java Memory Models möglich und Java erlaubt also sozusagen, dass alle drei Verhalten nämlich für immer in der Schleife bleiben, True ausgeben oder False ausgeben hier möglich sind. Als zweites Beispiel wollen wir uns noch mal ein Stück Java angucken und zwar diesmal eins, was wir noch nicht gesehen haben und das ist ein Quiz für Sie. Also ich würde Sie bitten, das Video dann hier mal kurz anzuhalten zu überlegen, was dieser Code denn eigentlich macht und was der Schluss endlich auch ausgeben kann und das dann entsprechend im Ilias abzustimmen. So, schauen wir uns mal an, was hier passiert. Also die Antwort ist, dass im Prinzip alle Werte zwischen 1,2 und 3,4 rauskommen können. Also es könnte 2,2 rauskommen, es könnte 1,2 rauskommen, es könnte 3,3 rauskommen und es kann auch 3,4 rauskommen und auch noch die anderen inzwischen, die ich jetzt nicht gesagt habe und der Grund ist ganz einfach wieder das Java Memory Model und die Tatsache, dass diese Zugriffe auf das Array R nicht ordentlich synchronisiert sind. Also schauen wir uns uns mal ein bisschen Schritt für Schritt an. Wir haben hier dieses Array R, in dem Initial erstmal eins und zwei drin steht. Dann haben wir zwei Threads, T1 und T2, die jeweils dieses Array R benutzen und jeweils an das erste und zweite Element eins hinzufügen, also sozusagen diese eins hier zu zwei machen und diese zwei hier zu drei machen und das dann aber zweimal, weil es ja zwei Threads sind. Jetzt ist das Problem, dass diese Zugriffe synchronisiert sein müssen, um sicherzustellen, dass tatsächlich immer der neuste Wert überschrieben wird und ich nicht einfach nur den alten Wert überschreibe und auch um sicherzustellen, dass hier unten, wo ich im Main Thread dann die Ergebnisse lese, ich tatsächlich auch die geschriebenen Werte sehe und nicht irgendwelche alten Werte von A, zum Beispiel die Werte, die A hier oben drin hatte. Was der Code jetzt macht, ist, er hat synchronized hier stehen in dem Code von Thread 1, aber eben nicht im Code von Thread 2 und auch hier unten, wenn wir lesen ist, nicht synchronisiert. Das heißt, es kann tatsächlich alles rauskommen, weil eben nicht klar ist, welche der schon geschriebenen Werte jeweils dann in dem anderen Thread gelesen werden können. Wer es nicht glaubt, kann das gerne mal ausführen und vielleicht einfach mal tausendmal ausführen oder so und ausgeben, was dabei jeweils rauskommt und dann kann ich auch mal was rauskommen, was eben nicht der Wert ist, den man vielleicht erwarten würde, nämlich nicht 3,4, sondern manchmal kommt da auch was anderes raus. Gut, und damit sind wir auch schon wieder am Ende des dritten von den vier Teilen, die wir hier im Themenblock Concurrency haben. Sie haben jetzt also gesehen, wie bestimmte grundlegende Synchronisationskonstrukte, wie busy waiting, spin logs and barriers implementiert werden können und Sie haben auch gesehen, welche Feinheiten es bei der Frage zu beachten gibt, inwiefern denn Zugriff auf Speicher, die parallel stattfinden, tatsächlich für den anderen Thread sichtbar sind und dass das Java Memory Model da manchmal für Überraschungen sorgen kann. Im vierten und letzten Teil schauen wir uns dann an, wie wir zum Beispiel in Java mit Hilfe der richtigen Primitive dann doch das Verhalten bekommen können, was man vielleicht als Programmierer wartet und definitiv auch haben will und bis dahin erst mal danke fürs Zuhören und bis zum nächsten Mal.