 Ja, herzlich willkommen zurück zu Programmierparadigmen. Wir sind jetzt hier im zweiten von vier Videos zum Thema Concurrency und in diesem zweiten Video soll es um grundlegende Konzepte gehen, die in den meisten Programmiersprachen die Concurrency unterstützen in irgendeiner Form auftreten. Fangen wir erstmal an mit ein paar Begrifflichkeiten und zwar Begriffe für diese Dinge, die der Parallel ausgeführt werden. Da gibt es drei Begriffe, die hier relevant sind, nämlich Prozess, Thread und Task. Die sind relevant auf verschiedenen Leveln und zwar, sprich mal von Prozessen im Zusammenhang mit Betriebssystemen. Also im Betriebssystem gibt es dieses Konstrukt namens Prozess, was parallel oder nebenläufige Aktivitäten innerhalb des Betriebssystems beschreibt. Jetzt hier in der Vorlesung interessieren wir uns nicht so sehr für das Betriebssystem, sondern mehr für die Programmiersprache, sprich die wichtigsten beiden Begriffe im Kontext dieser Vorlesung sind die nächsten Beinen. Der erste hier ist Thread und was ich damit meine, ist einfach eine aktive Entität, die der Programmierer als etwas betrachtet, was parallel oder nebenläufig mit anderen solchen aktiven Entitäten ausgeführt wird. Also das ist in der Regel das zentrale Konstrukt, was es in der Programmiersprache gibt, mit dem man eben diese parallelen Aktivitäten beschreiben kann. Und dann gibt es noch den Begriff des Tasks und was hiermit gemeint ist, ist eben kein konkretes Konstrukt, was eine bestimmte Programmiersprache zur Verfügung stellt, sondern eine logische Einheit von Arbeit, die irgendwann ausgeführt werden muss und die dann schlussendlich von einem Thread ausgeführt wird. Also die Tasken sozusagen das, was der Programmierer als logische Einheiten dessen was ausgeführt werden muss, sieht, die werden dann abgebildet auf die Threads und die Threads werden schlussendlich von der Programmiersprache Implementierung auf die Prozesse des Betriebssystems abgebildet. Wichtig ist hier, dass dieser Begriff des Threads nicht gleichzusetzen ist mit dem Thread, wie man ihn vielleicht in bestimmten Programmiersprachen zum Beispiel Java sieht, sondern das ist ein allgemeines Konzept. Die Java Threads sind auch Threads, aber was ich hier mit Thread meine, ist eben nicht nur Java Threads. Und was auch wichtig ist, dass in anderen Programmiersprachen außer Java der Begriff der für dieser sogenannten Threads verwendet wird, manchmal ein anderer ist und ich versuche hier in der Veranstaltung die Begriffe so zu verwenden, wie sie hier auf der Folie stehen und das dann jeweils auf die entsprechende Programmiersprache abzubilden, wenn es um eine bestimmte Sprache geht. So, wenn wir jetzt diese parallel ausführenden Threads in einer Programmiersprache haben, dann will man oft, dass die auch miteinander kommunizieren, zum Beispiel um gewisse Informationen miteinander auszutauschen. Und da gibt es im Grunde zwei Arten und Weisen, wie das in Programmiersprachen üblicherweise gelöst wird, nämlich Shared Memory und Message Passing. Shared Memory bedeutet, dass es bestimmte Variablen gibt, die von verschiedenen Threads benutzt werden können. Also verschiedene Threads können diese Variablen lesen und schreiben, weil die eben geteilter Speicher sind und über diese geteilten Variablen oder diesen geteilten Speicher kommunizieren diese Threads dann miteinander. Die zweite Variante, Message Passing, bedeutet, dass es keinen solchen geteilten Speicher gibt, sondern dass die Threads sich explizit Nachrichten zu schicken und darüber kommunizieren. Manche Sprachen haben das eine, manche Sprachen haben das andere und es gibt natürlich auch Sprachen, die beides anbieten und in dem beides häufig verwendet wird. Nur mal Beispiele zu geben, also Shared Memory ist das, was man üblicherweise zum Beispiel in Java oder auch C++ haben würde. Message Passing findet man zum Beispiel in äkterbasierten Sprachen wie VR lang. Und Programmiersprachen, die beides anbieten, sind zum Beispiel Scala, wo man sowohl das Äktermodel benutzen kann, als auch über Threads mit Shared Memory kommunizieren kann. Wenn die parallelen Threads jetzt miteinander kommunizieren, dann will man natürlich nicht nur, dass irgendwann diese Nachricht mal bei einem anderen Thread ankommt, sondern man möchte oft auch noch synchronisieren. Das heißt, man braucht irgendein Mechanismus, um die relative Reihenfolge der Operation, die in den verschiedenen Threads stattfinden, miteinander in Einklang zu bringen oder zumindest festzulegen, in welche Reihenfolge die Dinge tatsächlich passieren. Oft will man nicht die ganz genaue Reihenfolge festlegen, sondern eben nur an bestimmten Punkten sagen, das muss jetzt aber vor diesem passieren, damit das Programm schlussendlich diese Mandik hat, die man gerne hätte. Im Shared Memory Model muss diese Synchronisierung explizit geschehen, das heißt, man muss, wenn eine Variable gelesen wird, explizit dafür sorgen, dass diese Variable tatsächlich den neusten Wert der Variable liest und nicht vielleicht irgendeine alten Wert, sofern man das dann möchte. Im Message Passing Modell ist das ganze implizit, denn dadurch, dass diese Nachrichten explizit verschickt werden, stellt der Thread, der die Nachricht an den anderen Thread abschickt, natürlich sicher, dass die Nachricht erst ankommt, nachdem sie abgeschickt wurde. Also durch das Nachrichten schicken hat man im Prinzip implizit so einen Synchronisierungspunkt, weil dem gesagt wird, die Nachricht kommt erst an, nachdem sie geschickt wurde. Egal, ob die Synchronisierung jetzt implizit oder explizit ist, ist die Frage, wie genau das dann eigentlich implementiert wird. Wir werden in dem späteren Teil der Veranstaltung noch sehen, wie das passiert, aber im Grunde gibt es da zwei große Kategorien, nämlich Spinning und Blocking. Also Spinning, manchmal wird es auch Busy Waiting genannt, bedeutet, dass ein Thread eine bestimmte Bedingung immer wieder evaluiert, bis sie dann wahr ist. Das heißt, der Thread wartet sozusagen aktiv darauf, dass etwas so ist, wie es sein muss, damit der Thread weiter machen kann und schaut aktiv immer wieder nach, ob das jetzt schon soweit ist. Blocking hingegen bedeutet, dass der Thread seine eigene Berechnung stoppt, bis diese Bedingung wahr ist. Und er muss sozusagen nicht aktiv immer nachschauen, sondern es gibt einen Scheduler, der diesen Wattenden Thread schlussendlich wieder aktiviert. Also gibt diese zwei Formen, beides wird tatsächlich praktisch verwendet und manchmal möchte man das eine, manchmal möchte man das andere. Und wie genau diese beiden Formen von Synchronisierung implementiert wird, schauen wir uns dann auch noch an. Um diese Konzepte jetzt mal so ein bisschen auf konkrete Sprachen zu mapen, habe ich hier einfach mal ein paar Beispiele aufgelistet, wo wir zum einen sehen, auf welchem Level das Ganze überhaupt implementiert ist. Also ist es jetzt auf dem Level der Sprache selbst implementiert oder ist es eine Spracherweiterung, die man explizit anstellen muss, um das nutzen zu können oder ist es vielleicht die Bibliothek, in der das Ganze implementiert ist. Und hier oben sieht man dann diese verschiedenen Formen der Kommunikation, die möglich ist, zum einen nämlich Shared Memory, Message Passing und dann noch eine dritte Formen, die ich bis jetzt noch nicht erneutert habe, aber die im Prinzip eine Spezialform von Message Passing ist, nämlich Distributed Computing, wo wir Message Passing ausführen, aber auf eine Art und Weise, dass es zwischen verschiedenen Computern stattfindet. Wenn wir uns jetzt mal die Sprachen anschauen, dann sehen wir zum Beispiel, dass in Java Csharp oder auch C und C++ eben das Shared Memory Model benutzt wird, wohingegen Message Passing zum Beispiel in Go zu finden ist, oder auch in Erlang, in Erlang noch mit der Spezialität, dass das eben nicht nur auf einem Computer, sondern ohne große Veränderung des Kurs dann auch auf mehreren Computern stattfinden kann. Es gibt dann natürlich auch Spracher-Weiterung, die ähnliche Sachen implementieren, also OpenMP, eine Spracher-Weiterung für C, die wir auch noch sehen werden. Es gibt noch reichhaltigere Sonntags, um Shared Memory-Synchronisierung und Kommunikation auszudrücken. Wenn man das Ganze dann auf verschiedenen Rechneren machen will, dann ist eine Sprache weiter und die häufig verwendet wird, zum Beispiel der Remote Procedure Call, der es erlaubt von einem Programm, was auf einem Computer läuft, eine Funktion auf einem anderen Computer aufzurufen und das ist schlussendlich eine Form von Message Passing. Und dann gibt es natürlich noch eine ganze Reihe von Bibliotheken, die diese Konzepte implementieren, also P-Threads zum Beispiel, ist die Standard-Bibliothek, die man für CAC pluslos in der Regel verwendet oder Windows-Threads ist so ähnlich. Wenn man Message Passing verwenden will und das noch nicht Teil der Sprache selbst ist, kann man zum Beispiel das API, das Message Passing Interface verwenden und im Bereich Distributed Computing gibt es zum Beispiel eine ganze Reihe von Bibliotheken, die es erlauben, Software über diverse Internet-Protokolle zu kommunizieren zu lassen. So, jetzt wissen Sie, was Threads sind und Sie wissen, dass die miteinander kommunizieren müssen und dass man das dann gelegentlich auch mal synchronisieren möchte, was wir jetzt machen werden, ist uns anzuschauen, wie das in verschiedenen Programmiersprachen überhaupt umgesetzt werden kann und wir hängen das Ganze quasi daran auf, wie überhaupt so ein Thread erstellt wird und daraus ergibt sich dann auch oft wie genau die eigentlich miteinander kommunizieren können. Wir werden uns der fünf Arten und Weisen, wie das in den Programmiersprachen, die so üblicherweise verwendet werden, gemacht werden kann. Co-beginn, parallele Loops, das sogenannte Launch-At-Ellaboration, Fork, manchmal dann noch mit Join dazu und schlussendlich Implicit Receipt. Wir werden uns anschauen, was diese fünf Konstrukte sind und ich werde noch mal Beispiele für konkrete Sprachen geben, wo das stattfindet. Die erste Art, mehrere Threads zu erstellen, ist das sogenannte Co-beginn. Hier handelt es sich um eine Art, eine spezielle Art von Statement, in der mehrere Statements drin sind, die dann aber alle nebenläufig oder parallel ausgeführt werden, also Implicit sozusagen jeweils einen Thread erstellen, indem die Ausführung dann stattfindet. Bei einem ganz abstrakten Pseudokot-Gesprochen sieht das so aus, ich habe irgendetwas, was sagt, hier geht dieses Statement los, hier ist es dann zu Ende und in diesem Compound-Statement drin sind dann weitere Statements und wenn wir dieses äußere Statement erreichen werden für jedes Statement, was da innen drin steht, also Statement 1, Statement 2 bis Statement N, jeweils ein Parallel der Thread gestattet, indem dann dieser Statements 1 bis N ausgeführt werden. Als ein Beispiel einer Sprache oder Sprach-Erweiterung, genauer gesagt, wo das Ganze so stattfindet, schauen wir uns mal C mit OpenMP an. OpenMP ist eine Erweiterung für C, die das erstellen und synchronisieren von Threads auf vielelei Arten vereinfacht und zwar funktioniert das mit Hilfe von sogenannten Pragmas. Also was Pragmas sind, sind im Prinzip spezielle Kommentare, die aber der Compiler nicht einfach nur ignoriert wie die meisten anderen Kommentare, sondern die dem Compiler bestimmte Anweisung geben, also das sind sogenannte Compiler Directives. Das funktioniert so in C, dass man dieses Hash-Zeichen, was normalerweise auch ein Kommentar beginnt, als allererstes Zeichen in der jeweiligen Zeile haben muss und dahinter erfolgt dann ein spezieller Kommentar, der nämlich losgeht mit Pragma, dann OMP für OpenMP, um zu sagen, dass das ein OpenMP Pragmas ist und dann steht dahinter, was da eigentlich passiert. Und was hier passiert ist, dass wir eben so ein Co-Begin-Statement hier einleiten, indem wir sagen, jetzt kommen OpenMP Sections, dann haben wir hier so ein Block, in dem die eigentlichen Sections dann drin sind und jede Section selbst wird dann wieder nochmal durch den Pragma eingeleitet, was sagt, hier geht die Section los, das ist dann auch wieder durch den Block umrandet und darin steht dann das, was schlussendlich in dem jeweiligen Thread parallel ausgeführt werden soll. Also was wir hier tatsächlich machen ist, es werden zwei Threads gestattet, die jeweils einmal PrintDef aufrufen und sagen, hey wir sind da und diese Threads werden dann parallel ausgeführt. Die zweite Form, wie parallele Threads gestattet werden können, die es auch in einigen Programmiersprachen gibt, sind die parallelen Schleifen. Da hat man vorhin ja schon mal so ein kleines Beispiel gesehen, also die Grundidee ist, dass es eben nicht so ist wie bei den normalen Schleifen, bei den normalen Schleifen werden ja die einzelnen Iterationen nacheinander ausgeführt, sondern die Iterationen der Schleife finden nebenläufig bzw. parallel statt. Hier sind mal zwei Beispiele von konkreten Sprachen bzw. Spracherweiterung oder sogar Bibliotheken, wo man das dann sehen kann. Zum einen erst mal wieder C mit OpenMP, also das, was wir gerade eben schon gesehen haben. Da haben wir hier wieder so ein Pragma, wieder eingeleitet durch Hash und Pragma und OMP und in dem Fall ist das das Pragma für parallel for, was eben so eine parallele Vorschleife einleitet. Und was dann hier passiert, ist, dass wir diese Vorschleife, die genauso aussieht wie jede andere Vorschleife in C, auch parallel ausführen werden. Das heißt, diese drei Iterationen dieser Schleife finden nicht nacheinander, sondern parallel statt. Also wir werden drei Threads haben, die dann jeweils ausgeben, Thread 1, 2 oder 3 bzw. 0 bis 2, ist da. Ein zweites Beispiel ist aus C Sharp und zwar mit Hilfe der Task Parallel Library, die wir vorhin ja schon mal kurz gesehen haben. Da ist wieder dieses parallel for, was eben auch drei Iterationen 0, 1 und 2 erstellt, die dann jeweils diese Funktion, die hier zu sehen ist, ausführt und für die Iteration der Schleife dann jeweils auch wieder ausgibt, dass jetzt eben diese Iteration stattfindet und genauso wie in dem ersten Beispiel, wenn diese Iteration eben parallel ausgeführt. Das heißt, wir bekommen zum Beispiel auch nicht unbedingt diese Ausgaben dann in der Reihenfolge 0, 1, 2, sondern die können in jeder beliebigen Reihenfolge ausgegeben werden, weil wir gar nicht wissen, welche dieser Threads jetzt zuerst tatsächlich die Ausgabe macht. Wenn wir jetzt so eine Parallel Schleife haben, ist eine Frage, was für eine Art von Synchronisation zwischen den einzelnen Iterationen gegebenenfalls stattfindet. Also, wenn wir innerhalb dieser Iteration auf oder innerhalb dieser Schleifenbodies, die dann parallel ausgeführt werden, in irgendeiner Art und Weise ein Data Race haben, also auf dieselben Daten lesend oder schreiben zugreifen, ist die Frage, wie kann man verhindern, dass da dann nicht das passiert, was man eigentlich möchte. In den meisten Programmiersprachen liegt das allein in der Verantwortung des Programmiers, das heißt, die Sprache macht da erstmal gar nichts, sondern man müsste, wenn man da synchronisieren möchte, innerhalb des Schleifenbodies dann tatsächlich noch Synchronisationskonstrukte verwenden, zum Beispiel LOX, schauen wir uns dann später noch ein bisschen genauer an. In manchen Sprachen, und das möchte ich jetzt hier erstmal zeigen, gibt es aber dann doch eine implizite Synchronisierung von Seiten der Programmiersprache. Das heißt, da wird an bestimmten Stellen synchronisiert zwischen den Threads, die parallel ausgeführt werden. Ein Beispiel dafür ist eine Form von Parallel Schleife in Fortran, nämlich die sogenannten For All Loops, die es da gibt. Und was da passiert, ist, dass bei jedem Assignment, was innerhalb der Schleife stattfindet, implizit synchronisiert wird. Das heißt, immer wenn ein Assignment stattfindet, warten alle Threads aufeinander und erst dann geht es weiter. Und speziell ist das so, dass alle Sachen, die auf der rechten Seite eines Assignments gelesen werden, geschehen, bevor irgendwelche Schreibaktionen passieren, die auf der linken Seite des Assignments sind. Das heißt, alle Threads Wesen, erst das, was rechts gelesen wird, und dann, nachdem alle gelesen haben, schreiben dann alle entsprechend dem, was auf der linken Seite des Assignments steht. Schauen wir uns das Ganze mal an einem Beispiel an und anschließend habe ich dann auch noch ein kleines Quiz zu dem Beispiel. Also was wir hier sehen ist, eben so eine Parallele For All Loop in Fortran. Was hier passiert ist, wir haben also wieder so eine Schleifen, eine Schleifenvariable i, die hier von 1 bis n-1 gehen wird und für jede dieser Iteration wird dann implizit ein Thread erstellt, weil es eben eine Parallele For Schleife ist. Das heißt, diese Iteration werden parallel ausgeführt. Was machen diese Ausführungen dann jeweils? Die werden jeweils bestimmte Array Elemente, nämlich aus den Arrays A, B und C lesen und schreiben, aber die machen das nicht in einer komplett beliebigen Reihenfolge, sondern diese Assignments, die hier stattfinden, sind eben implizite Synchronisationspunkte. Das heißt, was schlussendlich passiert ist, dass zuerst alle Threads in dieses erste Statement ausführen werden und davon aber erstmal all die Sachen, die auf der rechten Seite gelesen werden ausführen werden. Also alle Threads lesen sozusagen den jeweiligen Wert von B an Position I und von C an Position I. Anschließend schreiben dann alle Threads das Ergebnis, was rauskommt, nachdem sie gelesen haben und die Plus-Operation ausgeführt haben, in A von I. Und dann gibt es zum nächsten Statement, wo dann alle Threads diesen Wert lesen, der hier gerade eben geschrieben wurde. Und dann wird wieder synchronisiert und gewattet und erst nachdem das alle gelesen haben, wird dann der nächste Wert an A von I plus 1 geschrieben. Das heißt, durch diese implizite Synchronisierung an den Assignments steht im Prinzip fest, in welche Reihenfolge das Ganze hier passiert. Und der Programmierer muss nicht explizit festlegen, dass hier Synchronisation oder Synchronisierung verwendet werden soll. Um das noch ein bisschen mehr zu verdeutlichen, schauen wir uns mal ein kleines Quiz an, und zwar mit demselben Stück Code, was wir gerade eben gesehen haben. Also das ist wieder diese parallele For All Loop aus Fortran. Und die Frage ist jetzt, was passiert eigentlich, wenn wir diese Schleife jetzt ausführen, und zwar mit diesen konkreten Werten hier unten. Also wir haben Werte für das Array A, für das Array B und für das Array C und wir wissen auch den Wert von N nämlich gleich 3. Und die Frage ist, welchen Wert hat das Array A, nachdem wir den Code mit diesen Werten ausführen. Noch mal als Hinweis, in Fortran fangen die Array Indices nicht bei 0 an, wie zum Beispiel in C oder Java, sondern bei 1. Also das allererste Element eines Arrays ist das Array von 1, der Element in diesem Array. Ich würde Sie jetzt bitten, darüber kurz nachzudenken, vielleicht auch mal auf Papier aufzuschreiben, was hier eigentlich vor sich geht. Anschließend dann im Ilias abzustimmen, auch so als kleine Eigenkontrolle, ob Sie es verstanden haben. Und anschließend dann erst weiterzuschauen. So, schauen wir uns mal die Lösung an. Also wir haben hier N gleich 3. Das bedeutet, wir haben zwei Threads, nämlich einen in dem I gleich 1 sein wird und ein anderer in dem I gleich 2 sein wird. So, und jetzt schauen wir uns an, welche Statements da ausgeführt werden. Das erste ist das Assignment Statement, weil wir eben diese implizite Synchronisierung haben, wird zunächst erstmal alles gelesen, was auf der rechten Seite des Assignments steht. Das heißt Thread 1 wird das erste Element von B lesen und das erste Element von C, also 1 und 3 und die dann addieren und bekommt da den Wert 4 raus. Und Thread 2 liest jeweils das zweite Element, also der liest 2 aus B und eine andere 2 aus C addiert die und bekommt den Wert 4. Anschließend geschieht eben genau diese implizite Synchronisierung, die ich gerade schon erklärt habe. Also bevor das Assignment jetzt wirklich stattfindet und wir die Ergebnisse in A von I schreiben, warten erstmal alle Threads aufeinander. Und es wird sichergestellt, dass alle die Werte auf der linken Seite tatsächlich gelesen haben. So, jetzt können wir die linke Seite des Assignments ausführen. Das heißt wir werden an die It-Stelle von A schreiben, also in Thread 1 schreiben wir an A von 1, und zwar den Wert, den wir gerade ausgerechnet haben, nämlich 4. Und so ähnlich dann in Thread 2, wo wir an das zweite Element von A schreiben und da auch eine 4 hinschreiben. Das heißt in A haben wir jetzt nicht mehr 0, 0, 0 wie am Anfang, sondern 4, 4, 0, nachdem dieses erste Statement ausgeführt wurde. So, jetzt haben wir einen weiteren Synchronisationspunkt hier und zwar deshalb, weil wir ein weiteres Assignment haben und alles, was jetzt da auf der rechten Seite gelesen wird, geschieht auch erst nachdem alles, was in dem vorigen Assignment tatsächlich geschrieben wurde. Das heißt, wir warten erst wieder bis alle Threads tatsächlich in A von 1 bzw. A von 2 geschrieben haben und lesen dann die Werte von A von i und A von i plus 1. Das heißt, im Thread 1 lesen wir das erste und zweite Element von A, also 4 plus 4, addieren die und bekommen dann 8 raus. Und im Thread 2 lesen wir das zweite Element von A, da ist die 4 und das dritte Element von A, da ist die 0 und addieren die wieder und kommen dann auf 4. So, und dann gibt es nochmal so einen impliziten Synchronisationspunkt, das ist auch der letzte für das Beispiel, bevor wir dann schlussendlich das zweite Assignment abschließen, indem wir die Sachen, die auf der linken Seite des Assignments stehen, tatsächlich schreiben. Also, jeweils an A von i plus 1 das Ergebnis schreiben, das heißt, in Thread 1 schreiben wir an A von 2 dies 8 und in Thread 2 schreiben wir an A von 3 den Wert 4. Und das bedeutet, dass schlussendlich der Endzustand von unserem Array A ist, dass wir darin die 4 haben, die wir hier oben geschrieben hatten, dann die 8, die wir hier geschrieben haben und schlussendlich dann die 4, die hier drüben geschrieben wurde. Also, schlussendlich haben wir 4, 8, 4 in diesem Array drin stehen. So, jetzt wissen Sie, was parallele Loops sind. Sie haben gesehen, wie in manchen Programmiersprachen implizit zwischen den einzelnen Statements, die der Parallel ausgeführt werden, synchronisiert wird. Synchronisieren muss man aber natürlich nur, wenn überhaupt Daten gemeinsam geteilt werden, denn wenn jeder Thread nur seine eigenen privaten Daten hat, dann muss man auch nichts synchronisieren. In manchen Programmiersprachen bzw. Spracherweiterung kann man genau spezifizieren, welche Variablen dann überhaupt geteilt werden soll und dann muss man auch nur bei diesen Variablen tatsächlich irgendeine Form von Synchronisierung machen. Ein Beispiel, wo das geht, ist OpenMP, also diese C-Erweiterung, die wir jetzt ja schon ein paar Mal gesehen haben, und zwar gibt es da im Prinzip 3 Modi, mit denen festgelegt werden kann, inwiefern diese Daten dann zwischen den parallelen Threads überhaupt geteilt werden. Das eine ist SharedData, also das, was wir bis jetzt auch implizierte mal angenommen haben, was einfach bedeutet, alle Threads benutzen dieselben Daten und haben dann SharedMemory und müssen, wenn das gewünscht ist, das natürlich dann auch entsprechend synchronisieren. Der zweite Modus ist PrivateData, was bedeutet jeder Thread hat seine eigene Kopie von einer bestimmten Variablet zum Beispiel, die dann auch mit den anderen Threads nie in irgendeiner Form geteilt wird. Und das Dritte ist so ein Zwischending, das nennt sich Reduction, was bedeutet, dass eine Variable im Prinzip erstmal Private ist, aber am Ende dieser parallelen Loops werden die einzelnen Werte, die die Threads für diese private Variable errechnet haben, dann zusammengeführt oder reduziert auf einen Wert, der schlussendlich dann auch nach der Schleife zur Verfügung steht. Als konkretes Beispiel schauen wir uns mal diese parallele Schleife, die wieder in C mit OpenMP geschrieben ist an. Also wir haben ja erstmal zwei Variablen, einmal dieses Array der Größe n und dann diese Variable sum und dann kommt wieder so ein Pragma, was diese parallele Schleife in OpenMP einleitet. Also das ist wieder Pragma OpenMP und dann Parallel4 und dann das, was jetzt hier neu daran ist, sind diese Anweisungen, inwiefern die Daten, um dies hier geht, dann überhaupt gemeinsam oder auch privat sein sollen. Und was wir hier sagen ist, mit diesem Default shared, dass alle Variablen, außer der Schleifenvariable i, die ist immer private, zwischen den parallel ausgeführten Schleifeniterationen geshared sein sollen, aber und das ist die Ausnahme, wir geben auch an, dass diese sum-Variable eben nicht geshared ist, sondern private sein soll, aber schlussendlich dann reduziert werden soll. Das heißt, am Ende der parallele Ausführung wird das Ergebnis dieser sum-Variable der einzelnen Strats genommen und auf einen Wert reduziert, nämlich mithilfe dieses Plus-Operators. Also was diese Schleife schlussendlich machen wird, ist, sie wird sich dieses gemeinsame Array parallel anschauen und jeweils aufsummieren, welche Werte denn am ieten Element des Arrays sind. Und die Summe, die all diese Array-Elemente, ist schlussendlich, nachdem die Schleife ausgeführt wurde, also wenn wir dann hier sind, in dieser sum-Variable, denn diese einzelnen Summen werden implizit durch diese Reduktionsanweisungen hier oben durch den Plus-Operator zusammengeführt zu einer einzelnen Summe. So, die ersten zwei Arten, wie man Threads im Programmiersprachen üblicherweise erstellen kann, haben wir jetzt angeschaut. Das war Co-Begin und die Paralleloops. Jetzt kommen wir zur dritten Art, nämlich das sogenannte Launch-Add-Elebration. Die Grundidee hier ist, dass wir einen Thread mit einer bestimmten Ausführung einer Subroutine assoziieren können und damit sagen, wann immer diese Subroutine ausgeführt wird, dann wollen wir auch diesen Threads noch zusätzlich starten, zusätzlich zu dem Thread, der ja schon läuft und die Subroutine ausführt, so dass wir parallel zu der Subroutine noch etwas anderes machen können. Und am Ende der Subroutine warten wir dann, bevor die Subroutine zurückkehrt, dass dieser Thread auch fertig ist. Das heißt, also das, was parallel zu der Subroutine gemacht wird, muss dann auch beendet sein, bevor die Subroutine wieder zurückkehrt. Interessanterweise kann dieser Thread, der dann parallel zu der Subroutine gestartet wird, all die lokalen Variablen dieser Subroutine auch verwenden. Das heißt, wir können sozusagen in dieser Subroutine so ein kleines Stück Parallelität einbauen und die Variablen, die in dieser Subroutine existieren und da auch benutzt werden, wenn dann geteilt zwischen den parallel ausführten Threads. Schauen wir uns mal ein konkretes Beispiel an, und zwar in der Ada-Sprache. Also was wir hier sehen, ist einmal, wie eine Subroutine namens P erstellt wird und hier unten zwischen dem Beginn und End haben wir dann den eigentlichen Body dieser Routine und die Statements, in dem Fall das eine Statement, das PutLine Statement, was ausgeführt wird, wenn diese Subroutine aufgerufen wird. Und zusätzlich dazu definieren wir hier diesen sogenannten Task und hier ist wieder ein bisschen verwirrende Technologie am Spiel, und zwar ist Task einfach nur der Name, den Ada für das verwendet, was ich hier in der Vorlesung Thread nenne. Also was hier erstellt wird, ist ein parallele Ausführungsstrang, der zusätzlich zu dem Ausführungsstrang, in dem dieses PutLine dann ausgeführt wird, eben auch noch existiert. Und parallel dazu eben das Ausführt, was wir dann hier oben sehen, nämlich ein weiteres PutLine. Das heißt, wenn diese Subroutine P aufgerufen wird, werden im Prinzip zwei Ausgaben gemacht, nämlich einmal das Hauptthreads hier unten und dann die das parallel laufenden Threads hier oben. Hier oben, in welcher Reihenfolge, ist nicht definiert, weil die eben ja nebenläufig oder parallel ausgeführt werden und keine Synchronisierung zwischen den beiden besteht. Die vierte Art, in der Threads in Programmiersprachen beschrieben und erstellt werden, ist Fork und dann in der Regel oder oft auch noch zusammen mit Join. Was heißt das? Also mit Fork bezeichnet man, dass Explizite erstellen eines Threads, also es gibt irgendeine Form von Statement, die Explizit sagt, so hier wird jetzt ein Thread erstellt. Und Join ist dann so ein bisschen das Pronon dazu, wo darauf gebatt wird, dass ein Thread, der vorher gefolgt wurde, fertig wird und wir Explizit sagen, an der Stelle batte auf genau diesen Thread und mache erst weiter, wenn dieser Thread tatsächlich auch seine Berechnung beendet hat. Schauen wir uns das Ganze mal an zwei Beispielen an und zwar als erstes ein Beispiel von Java. Also in Java kann man eben genau dieses Fork Join Modell verwenden, um Threads zu erstellen und dann darauf zu warten, dass die auch wieder fertig sind und zwar funktioniert das so, dass ich eine Klasse erstelle, die eine Unterklasse der Threadklasse ist. Und in dieser Klasse, die ich da erstelle, muss ich dann eine Run Methode angeben, in der schlussendlich der Code drin ist, der ausgeführt werden soll, wenn dieser Thread erstellt wird und dann auch ausgeführt wird. Also alles, was hier dann drin steht, wird eben in dem parallelen Thread, den wir hier erstellen, dann tatsächlich ausgeführt. Und wenn ich das Ganze dann benutzen möchte, muss ich diese Klasse natürlich erstmal inserziieren, indem ich hier diesen Konstruktor aufrufe und die Werte, die ich mit diesem Thread teilen möchte, übergebe ich dann üblicherweise hier als Argumente. Also die kommen dann hier oben in dem Konstruktor an und können dann in der Run Methode entsprechend auch verwendet werden. Und um diesen Thread dann tatsächlich zu starten, also das Erstellen des Objektes startet den noch nicht, sondern erstellt erst mal nur dieses Objekt der Subklasse Image Renderer. Und um das Ganze dann wirklich zum Laufen zu bringen, muss ich die Start Methode aufrufen. Dann wird dieser Parallel Thread ausgeführt. Hier kann ich in der Zwischenzeit irgendwas anderes machen, was dann parallel dazu stattfindet. Und wenn ich irgendwann möchte, dass es erst weitergeht, nachdem der Image Renderer Thread tatsächlich auch fertig ist, ruf ich dann die Join Methode auf, um hinzugeben, dass ich jetzt darauf warten möchte, dass dieser Thread auch beendet ist. Als zweites Beispiel, das im Prinzip dasselbe Modell verwendet, schauen wir uns mal C-Sharp an. Hier sieht das Ganze so ähnlich aus, aber es ist doch ein bisschen anders. Und der große Unterschied hier ist, dass der Code, der in dem Thread ausgeführt werden soll, im Prinzip nur eine ganz normale Methode ist, die wir dann in einen Thread verwandeln, indem wir diese Methode als First Class Entity in diesen Konstruktor von ThreadStart reingeben. Und der wird dann schlussendlich in den Konstruktor von Thread reingegeben. Und dadurch erstellen wir ein Thread, indem diese eine Methode als das Stück Code, das in dem Thread ausgeführt werden soll, angegeben ist. Das heißt, hier oben würde man wieder das reinschreiben, was in dem Parallel Thread auszuführen ist, erstellt diesen Thread dann so, wie man es hier unten sieht. Und dann haben wir ganz ähnlich wie zu Java wieder diese zwei Methoden Start und Join, die den Thread los starten. Anschließend kann man parallel noch irgendwas anderes machen und dann mit Join angeben, dass wir jetzt an der Stelle warten wollen, bis der Thread tatsächlich fertig ist. Wenn man jetzt in diesem Modell programmiert, wo man explizit Threads erstellt, starten lässt und den Ende dann mit Hilfe von Join abwattet, kann man schnell so in die Verlegenheit kommen, sehr, sehr viele Threads zu erstellen. Was aber doch relativ teuer ist, denn jeder dieser erstellten Threads hat eine gewisse Kosten, um diese Kosten zu sparen, gibt es dieses Konzept von Thread Pools. Also die Thread Pools haben im Prinzip zwei Vorteile. Das eine ist, wir müssen nicht so viele Threads erstellen, denn die Threads werden in Thread Pools wiederverwendet. Das heißt, für verschiedene logische Aufgaben, die vielleicht parallel ausgeführt werden sollen, kann ich denselben Thread benutzen, indem ich einfach dem einen Thread, wenn der fertig ist, mit der ersten Aufgabe die zweite Aufgabe gebe und die dann im selben Thread ausgeführt werden. Der zweite Vorteil ist, dass man als Programmierer die logischen Aufgaben, also das, was ich hier als Tasks bezeichne, von den eigentlichen Threads, also derart und Weise, wie das dann in der Programmiersprache implementiert wird, ein bisschen trennen kann und das quasi getrennt behandeln kann. Diese Implementierung eines Thread Pools spart sich dann also gewisse Kosten dadurch, dass Threads wiederverwendet werden und sie kann auch implizit entscheiden, wie genau die Task, die der Programmierer diesem Thread Pool übergibt, denn gescheduled werden, also in welche Reihenfolge, die genau ausgeführt werden. Das heißt, ich muss nicht selbst sagen, okay, dieser Threads macht die zwei Tasks und dieser Thread macht die drei Tasks, sondern das erledigt meinen Thread Pool für mich, indem ich einfach sage hier, es gibt diese Anzahl von Threads und hier sind diese Tasks für die Bitte mal raus. Schauen wir uns das Ganze mal wieder anhand von einem Beispiel an, und zwar diesmal wieder in Java. Das hier sieht jetzt so ähnlich aus wie das, was wir gerade gesehen haben. Also wir haben wieder diesen Image Renderer, der parallel zum Hauptthread des Programms irgendwas machen soll und dieses irgendwas steht hier wieder in dieser Methode namens Run drin. Der Unterschied ist, dass wir diesmal nicht von der Thread Klasse erben, sondern das Runnable Interface implementieren und das Runnable Interface sagt im Prinzip nur, dass es da irgendwo diese Methode namens Run gibt. Und was wir dann machen, ist, dass wir hier unten so ein Thread Pool erstellen, mithilfe dieser executers new fixed Thread Pool Methode, wo wir angeben, dass wir gern vier Threads erstellen wollen und diese vier Threads, die wir in diesem Thread Pool drin haben können, dann beliebig viele Tasks, die jeweils ein Runnable sein müssen, ausführen. Und als einen ersten Task übergeben, wie hier eine neue Instanz von diesem new Image Renderer an diese Execute Methode und sagen, hier ist der erste Task, den du ausführen sollst. Anschließend könnten hier aber noch, und das würde Sinn machen, wenn ich so ein Thread Pool mit vier Threads erstelle, weitere Tasks eingeführt werden, zum Beispiel könnte ich weitere Image Renderer Instanzen da übergeben und es können auch mehr als vier sein, die dann einfach eine nach dem anderen mithilfe der verfügbaren Threads abgearbeitet werden. Eine ganz interessante Spracherweiterung, in der diese Idee von Tasks, die man forkt und anschließend wieder gejoint werden, noch ein bisschen weiter getrieben ist, ist die Silk Sprache. Also Silk ist eine Erweiterung von C und die hat diese Konzepte fork und join unter dem Namen spawn und sync auch implementiert und gleichzeitig auch diese Idee von Thread Pools wirklich Teil der Spracherweiterung gemacht. Also die Grundidee hier ist, dass der Programmierer die Tasks und deren Abhängigkeiten explizit ausdrückt und dann aber nicht angeben muss, inwiefern diese Tasks jetzt tatsächlich auch Threads gemapped werden und ausgeführt werden. Sondern alles, was der Programmierer machen muss, ist zwei Keywords zu verwenden, nämlich spawn und sync. Was spawn macht, ist, dass es eine Funktion, die ein Task implementiert, als nebenläufigen Thread ausführen lässt. Das heißt also, wir sagen ruf bitte diese Funktion auf und macht das aber parallel zu dem, was anschließend kommt. Und dann gibt es noch sync, was sagt, dass alle Tasks, die jetzt irgendwo erstellt wurden, anschließend wieder synchronisiert werden müssen bzw. dass wir auf all diese Tasks warten und erst dann weiter wollen in der Ausführung. Und was die Implementierung dieser Spracherweiterung dann macht, ist, dass es die verschiedenen Tasks effizient auf die zur Verfügung stehenden Threads und Prozession mapped und zwar mit Hilfe vom sogenannten Work-Stealing. Was im Prinzip bedeutet, dass auf jedem Core ein Thread läuft, der sich immer den nächsten verfügbaren Task schnappt und den ausführt und das sozusagen so geschieht, dass die Prozessoren immer was zu tun haben. Und das führt dazu, dass wir schlussendlich so schnell wie möglich mit der kompletten Ausführung fertig sind, ohne dass der Programmierer sich explizit überlegen muss, wie die Tasks zu Threads und den CPU-Cores gemapped werden müssen. Schauen wir uns als Beispiel dafür mal die gute alte Fibonacci-Funktion an. Also hier sehen wir zunächst erstmal eine sequenzielle Implementierung, also komplett ohne Parallelität, wo wir einfach Fibonacci von n aufrufen und zwar indem wir die Fibonacci-Methode selbst rekursiv wieder aufrufen mit n-1 und n-2. So wenn wir das jetzt einfach so ausführen, passiert das ganze sequenziell, das heißt das wird erst Fibonacci mit n-1 aufgerufen, dann Fibonacci mit n-2 und das ergibt dann das Ergebnis von Fibonacci mit n. Wenn ich das ganze jetzt parallel ausführen möchte, kann ich das mit Silk sehr einfach machen, indem ich eben einfach diese weiteren Keywords hier anfüge. Zum einen muss ich diese Methode hier oben als spezielle Silk-Methode markieren, indem ich einfach Silk davor schreibe und dann habe ich diese beiden Keywords spawn und sync. Die Folgen des Machen, also das spawn sagt, dass diese Ausführung von Fib von n-1 bzw. von n-2 parallel mit dem parent geschehen soll, das heißt es wird also Fib von n und Fib von n-1 und Fib von n-2 alles parallel berechnet. Und sync sagt dann, dass wir an der Stelle schlussendlich darauf warten wollen, was diese hier gespawnen Ausführungsstränge gemacht haben, also an der Stelle geht es erst weiter, wenn die tatsächlich beide wieder fertig sind und das Ergebnis zurückgegeben haben. Also ist eine sehr einfache Art, diese Idee von fork und join bzw. spawn und sync auszudrücken, mithilfe dieser Spracherweiterung namens Silk. Die letzte Art und Weise, wie Threads im Programmiersprachen erstellt werden, ist eigentlich recht einfach, weil die passiert implicit und zwar ist das sogenannte implicit received. Also das ist relevant insystem, die diesen remote procedure call verwenden, also dieser Fernaufruf von Funktionen, wo ein Prozess, der vielleicht auf einer Maschine läuft, bei einem Prozess, der auf einer anderen Maschine laufen kann, eine Funktion aufruft. Und was da passiert ist, dass wenn dieser Request diese Funktion aufzurufen, bei der anderen Maschine ankommt, dann wird auf der Maschine, wo die Funktion ausgeführt werden soll, dafür ein Thread erstellt, in dem dann tatsächlich dieser Funktionaufruf stattfindet. Das heißt, der Programmierer muss im Prinzip gar nicht explicit Threads erstellen, sondern ruft einfach diese Funktion mithilfe von dem remote procedure call auf und dafür wird dann dort, wo der Aufruf stattfindet, ein Thread erstellt. So, schlussendlich noch ein kleines Quiz zum Abschluss, und zwar wieder in der Form von vier Sätzen, die ich mir ausgedacht habe, von denen manche stimmen und manche nicht stimmen. Und ich würde Sie bitten, diese Sätze jetzt mal durchzulesen, das Video anzuhalten, dann im Ilias abzustimmen, um zu sagen, welche von den Sätzen dann korrekt sind und welche nicht korrekt sind und erst dann weiterzuschauen. Okay, schauen wir mal die Lösung an. Also von diesen vier Sätzen war einer korrekt, nämlich der dritte, die anderen aber alle nicht. Der erste Satz war Concurrency, bedeutet, dass verschiedene Maschinausführungen zur gleichen Zeit haben. Das stimmt so nicht, denn das wäre Distribution. Concurrency bedeutet einfach nur, dass wir Nebenläufigkeit haben, also nicht genau, dass wir Paralleleausführungen haben, bei denen wir nicht genau wissen, an welcher Stelle in der Ausführung wir relativ zueinander sind, aber die müssen noch nicht mal zur gleichen Zeit laufen und die müssen auch nicht auf verschiedenen Maschinen laufen. In den parallelen Schleif von OpenMP sind die Daten bei Default eben nicht private für die jeweiligen Threads, sondern sind shared. Und man kann das dann aber durch diese Direktiven, die wir gesehen haben, noch verfeinern und zum Beispiel alle Daten oder bestimmte Daten private machen. Ein Thread in einem Threadpool kann tatsächlich eine beliebige Anzahl von Tasks ausführen, genau deswegen haben wir die. Denn dieser Thread wird wiederverwendet und kann mehrere Tasks nacheinander ausführen, ohne dass wir den overhead vom erstellen und wieder beendet eines Threads jedes Mal bezahlen müssen. Und dann noch der letzte Satz, der Scheduler reaktiviert ein Thread, der busyweighting macht eben nicht, sondern busyweighting heißt, der Thread schaut aktiv immer nach, ob die Bedingung, die erfüllt sein muss, damit es weitergeht, erfüllt es und macht dann weiter. Wenn wir Blocking verwenden würden, dann würde der Scheduler tatsächlich diesen Thread dann aufwachen, aber bei busyweighting ist das eben nicht der Fall. Ja, dann haben Sie mal auch am Ende dieses zweiten von vier Teilen. Vielen Dank fürs Zuhören und bis zum nächsten Mal.