Verteilte Algorithmen TI5005 Th. Letschert TH Mittelhessen Gießen University of Applied Sciences Algorithmen in Prozessnetzen Pipes und Filter Notation: syntaktische Schleife
Pipes und Filter Datenfluss / Dataflow Programming Datenfluss Archtitekturstil: Das System wird als eine Kombination von Datentransformatoren angesehen. Die Komponenten (Datentransformatoren) interagieren ausschließlich über den Austausch von Daten Varianten sequentielle Batch-Systeme Eine Komponente beginnt ihre Arbeit, wenn die vorhergehenden ihre abgeschlossen haben Pipes und Filter Die Komponenten arbeiten inkrementell: Daten werden nach Verfügbarkeit inkrementell an die nachfolgenden Komponenten weiter geleitet Prozess-Kontrolle / Spreadsheet-Systeme Die Komponenten repräsentieren Daten sie werden von werden von einem Prozess überwacht, der nach jeder Änderung einer Komponente alle abhängigen Komponenten neu berechnet Seite 2
Pipes und Filter Datenfluss / Dataflow Programming Strukturen von Datenfluss-Systemen Linear (Pipeline) in Stufen (keine Zykel) Beschränkte Zykel Beliebig Pipeline Visuelle Programmierung Datenflusssysteme eignen sich gut zur visuellen Programmierung Funktionale Programmierung lineare, gestufte und beschränkt zyklischen Datenflusssysteme sind / entsprechen funktionalen Programme Beschränkte Zykel können dabei mit Rekursion ausgedrückt werden gestuft beschränkte Zykel Seite 3
Pipes und Filter Pipes und Filter: Variante des Datenfluss-Stils / -Musters Filter Verarbeitungseinheiten Transformieren die Daten ihrer Eingabeströme Geben sie auf ihren Ausgabeströmen wieder aus Varianten aktive Filter Filter ist aktiver Prozess / Thread: holt die Daten aus den Pipes und schreibt sie in Pipes passive / reaktive Filter (betrachten wir später etwas genauer) Push-Filter: wird von seinem Vorgänger getrieben pull-filter: wird von seinem Nachfolger getrieben Pipe Transportiert Daten ist (in der Regel) passiv synchronisiert die aktiven Komponenten (Filter, Quellen, Senken) Kann puffern: Puffer-Kapazität 0 : Synchrones Netz Puffer-Kapazität >0 oder unendlich: Asynchrones Netz Quellen / Senken Spezielle Verarbeitungseinheiten ohne Eingabe- oder Ausgabe-Ströme Seite 4
Pipes und Filter Mergesort als Pipes und Filter Mergesort (Foliensatz 3) ist ein gestuftes Datenfluss-System in der Pipes-und-Filter-Variante mit aktiven Knoten channel in1, in2, out; in1 Merge = process { var v1, v2; v1 = in1.receive in2 v2 = in2.receive do { case (v1!= EOS and v2!= EOS) > case { v1 <= v2 > out.send(v1); v1 = in1.receive; v2 <= v1 > out.send(v2); v2 = in2.receive; case (v1!= EOS and v2 == EOS) > out.send(v1); v1 = in1.receive; case (v1 == EOS and v2!= EOS) > out.send(v2); v2 = in2.receive; out.send(eos) Seite 5 out
Pipes und Filter Beispiel Char-to-Line Transformation eines Zeichenstroms in einen Zeilenstrom channel input( char ), output( char[ 0..MAXLINE ] ); process CharToLine: char[] line; int i=0; do true : receive input( line[i] ) > { do line[i]!= CR && i < MAXLINE 1 > { i++; receive input( line[i] ); od send output(line[0..i 1]); i = 0; od Seite 6 Zeichen Zeilen
Monitor (Hoare, Brinch-Hansen, 1975) Ein Monitor ist ein Modul / Objekt, in dem die von Prozessen gemeinsam genutzten Daten und ihre Zugriffsprozeduren (oder Methoden) zu einer Einheit zusammengeführt sind. Monitor = Definition als Klasse mit Synchronisation Kapselt Daten mit ihren synchronisierten Zugriffsmethoden Synchronisation: Gegenseitiger Ausschluss Garantiert exklusiven Zugriff (Vermeide race conditions) Java: synchronized / Lock Bedingungssynchronisation Garantiert Zugriff unter den notwendigen Bedingungen (conditional synchronisation) Java: wait/notify, Condition Monitore sind passive Konstrukte Monitor basiert auf gemeinsamen Speicher Aktive Einheiten (Prozesse) greifen auf passive Ressourcen zu, die ihre Zugriffe synchronisieren Seite 7
Monitor Beispiel Puffer 3 Zustände: leer / nicht voll, nicht leer / voll Lesen: nur wenn nicht leer Schreiben: nur wenn nicht voll put Produzenten aktiv get Monitor Puffer passiv Konsumenten aktiv put : erwarte nicht voll while (!(count < SIZE)) wait(); Zustand count ~ Zahl der Elemente get: erwarte nicht leer leer ~ count > 0 nicht leer, nicht voll <count<size ~0 voll ~ SIZE Vorbedingungen put : nur wenn nicht voll => erwarte nicht voll get: nur wenn nicht leer while (! (count > 0)) wait(); + put : Benachrichtigen bei Modifikation von nicht leer count++; notifyall(); get: Benachrichtigen bei Modifikation von nicht voll count--; notifyall(); => erwarte nicht leer Seite 8
Monitor Beispiel Puffer Puffer mit einem Ablageplatz Lesen: nur wenn nicht leer Schreiben: nur wenn nicht voll class Buffer { var item : Int = -999 var empty : Boolean = true def put(x: Int) = synchronized { while (!empty) { wait empty = false item = x notifyall() object ProducerConsumer extends App { val buffer = new Buffer val producers = Array.tabulate(5){ i => new Thread(new Producer(i, buffer)) val consumers = Array.fill(5){ new Thread(new Consumer(buffer)) producers.foreach(_.start) consumers.foreach(_.start) producers.foreach(_.join) println("producers finished") Thread.sleep(100) System.exit(0) In diesem Beispiel wird nur eine Bedingungsvariable verwendet. def get() : Int = synchronized { while (empty) { wait empty=true notifyall() item class Producer(id: Int, buffer : Buffer) extends Runnable { val items = List(1,2,3,4,5); override def run() { items.foreach( item => { buffer.put(10*id + item) Thread.sleep(500) ) class Consumer(buffer : Buffer) extends Runnable { override def run() { while (true) { val item = buffer.get println(s"consume $item") Seite 9
Vom Monitor zum Server: Puffer als aktiver Monitor aktiver Puffer : Server als Nachrichten-verarbeitender aktiver Prozess: Server Produzenten und Konsumenten: Clients Wollen / können keinen gemeinsamen Speicher, oder Nutzen als Clients den aktiven Puffer als Server Produzent Konsument passiver Puffer Produzent passive Ressource aktive Nutzer: Monitor-Programm aktiver Puffer Konsument aktive Ressource aktive Nutzer: -Programm Seite 10
Vom Monitor zum Server: Puffer als aktiver Monitor Gemeinsamer Zustand ist nicht möglich Die Prozesse können nicht auf eine gemeinsame Ressource zugreifen. Die gemeinsame Ressource muss darum einen Server umgewandelt werden mit eigenem Kntrollfluss der die Aufträge als Nachrichten entgegen nimmt und bearbeitet. Aktiver statt passiver Monitor: Kommunikation statt Synchronisation Prozesse ohne gemeinsamen Zustand haben nur Synchronisationsanweisungen sind nicht mehr notwendig, da es keine gemeinsamen Ressourcen gibt: Gegenseitiger Ausschluss ist kein Thema mehr Die korrekte Reihenfolge der Verarbeitung muss aber weiterhin gewährleistet sein: Bedingungssynchronisation ist weiterhin ein Thema?? wait Produzent notify Konsument passiver Puffer?? Produzent aktiver Puffer?? Seite 11 Konsument
Vom Monitor zum Server: Puffer als aktiver Monitor Strategie der Umwandlung passiv => aktiv: synchronisierter Aufruf => Nachrichtenart, Empfang, Senden einer Antwort wait => Server Speichert Auftrag bis er ausführbar ist notify => Verarbeitung des gespeicherten Auftrags, Client-Benachrichtigung Verarbeite Nachrichten: Put-Nachricht sende OK-Antwort Get-Nachricht sende Daten-Antwort public class Buffer<TokenType> { TokenType place; boolean empty = true; synchronized void put(tokentype t) throws InterruptedException { while (! empty ) wait(); place = t; empty = false; notify(); synchronized TokenType get() throws InterruptedException { while ( empty ) wait(); empty = true; notify(); return place; Put-Nachricht mit ihrem Sender wird in WS gespeichert verzögerte Get-Nachricht verarbeiten Get-Nachricht mit Sender wird in WS gespeichert verzögerte Put-Nachricht verarbeiten Seite 12
Vom Monitor zum Server: Puffer als aktiver Monitor process BufferServer { channel in[0..n-1]; Queue[Int,Int]pendingPuts; Token place; channel out[0..n-1] Queue[Int] pendinggets; boolean empty = true; proc processpendingget() { if (! pendinggets.empty) { sender = pendinggets.remove() out[sender].send(place) empty = true; processpendingput(); do for i = 0 to n-1 true: in[i].receive(msg) -> case msg { Put(item) => if (!empty) { pendingputs.enqueue(msg, sender) else { place = msg.item; send OK to sender; empty = false; processpendingget(); proc processpendingput() { if (! pendingputs.empty) { (v, sender) = pendinggets.remove() out[sender].send(ok) place = v empty = false; processpendingget(); Get() => if (empty) { pendinggets.enqueue(sender) else { send Data(place) to sender; empty = true; processpendingput(); Notation mit syntaktischer for-schleife aktiver Puffer, Pseudocode Seite 13
Ressourcen-Allokation (Resource Allocation) Allokations-Monitor verwaltet eine Kollektion von Ressourcen beliebiger Art Nutzer fordern eine Ressource an, warten bis ihnen eine zugeteilt wird, nutzen sie, geben sie zurück. monitor Allocator { var avail = // nr available units var units = // units cond free // Condition variable def acquire(): Unit { if avail = 0 ~> free.wait avail > 0 ~> avail = avail-1 fi return removeone(units) def release(u: Unit) { units.insert(u) avail = avail+1 free.signal Seite 14
Ressourcen-Allokation (Resource Allocation) Allokations-Server portin request[0..n-1] potout reply[0..n-1] // attach n request-channels // attach n reply channels process Allocator { var avail = // nr available units var units = // units var pending : Queue<Msg>; Client do request[0].receive(acquiremsg) ~> if avail > 0 ~> avail = avail-1 reply[0].send(removeone(units)) avail = 0 ~> pending.add(0) Client... Server request[0].receive(releasemsg(u: Unit)) ~> if pending.size = 0 ~> avail = avail+1 units.insert(u) pending.size > 0 ~> reply[pending.remove()].send(u) Client aktiver Allocator, Pseudocode, kontrollorientierte Notation mit unidirektionalen Kanälen und Ports. Notation mit Elipse (Auslassungspünktchen) request[1].receive(acquiremsg) ~> if avail > 0 ~> avail = avail-1 reply[0].send(removeone(units)) avail = 0 ~> pending.add(1)... od Seite 15
Ressourcen-Allokation (Resource Allocation) Allokations-Server mit syntaktischer Schleife portin request[0..n-1] potout reply[0..n-1] // attach n request-channels // attach n reply channels process Allocator { var avail = // nr available units var units = // units var pending : Queue<Msg>; do for i = 0 to n-1 do request[i].receive(acquiremsg) ~> if avail > 0 ~> avail = avail-1 reply[i].send(removeone(units)) avail = 0 ~> pending.add(i) aktiver Allocator, Pseudocode, kontrollorientierte Notation mit unidirektionalen Kanälen und Ports. Notation mit syntaktischer for-schleife request[i].receive(releasemsg(u: Unit)) ~> if pending.size = 0 ~> avail = avail+1 units.insert(u) pending.size > 0 ~> reply[pending.remove()].send(u) od od Seite 16
Leser-Schreiber Leser-Schreiber Shared-Memory- / Monitor- Synchronisation Eine Ressource wird mit teilweise nicht exklusiven gegenseitigem Ausschluss genutzt. Leser dürfen gleichzeitig nutzen Schreiben müssen exklusiven Zutritt bekommen. Dies wird im shared memory Fall mit einer Zugangs-Kontrolle und einem Zugriffsprotokoll geregelt: startread / StartWrite : blockierende Monitor-Aufrufe read / write: unsynchronisierte eventuell überlappende Methoden der Ressource stopread / StopWrite: Monitor-Aufrufe mit notify 2. read Leser 1.startRead 3.stopRead 1. startwrite Schreiber ZugangsKontrolle (Monitor) 3. stopwrite 2. write Seite 17 Ressource
Leser-Schreiber Leser-Schreiber mit aktiver Zugangskontrolle Die kritischen Aktionen sind die zum Teil erlaubten gleichzeitigen Zugriffe auf die Ressource. Deren Kontrolle soll von einer aktiven Komponente übernommen werden. Die gleichzeitige Nutzung kann durch durch die Threads der Kunden (passive Ressource) ausgeführt werden Leser 3.stopRead 1. startwrite Schreiber 1.startRead 2. read read ZugangsKontrolle passive Ressource write 3. stopwrite 2. write Szenario 1 : aktive Kunden, passive Ressource, aktive Zugangs-Kontrolle. Interaktionen nur durch Nachrichten und Methodenaufrufe. Seite 18
Leser-Schreiber Leser-Schreiber mit aktiver Zugangskontrolle Die kritischen Aktionen sind die zum Teil erlaubten gleichzeitigen Zugriffe auf die Ressource. Deren Kontrolle soll von einer aktiven oder reaktiven Komponente übernommen werden. Die gleichzeitige Nutzung kann durch Threads der Ressource (aktive Ressource) ausgeführt werden Beispielsweise mit Threads der Ressource in einem Executor 2. read Leser 1.startRead 3.stopRead 1. startwrite Schreiber Executor ZugangsKontrolle Ressource 3. stopwrite 2. write Szenario 2: aktive Kunden, aktive Zugangskontrolle, aktive Ressource (Threadpool + aktive Bearbeitung der Nachrichten), aktiver Monitor zur Zugangs-Kontrolle. Interaktionen nur durch Nachrichten. Seite 19
Vom Monitor zum Server: Zusammenfassung Monitor aktiver Monitor ~> Server-Prozess Monitor-Nutzer ~> Client-Prozesse passiver => aktiver Monitor Klasse => Prozess Aufruf einer Monitor-Prozedur => Empfang einer Nachricht Körper einer Monitor-Prozedur => Verarbeitung der Nachricht, Ergebnis an den Sender Umsetzung Gegenseitiger Ausschluss völlig unproblematisch: Ein sequenzieller Serverprozess => streng sequenzieller Zugriff auf die Ressource Umsetzung Bedingungssynchronisation Der Serverprozess kann nicht (in wait) blockiert werden wait ~> Speichern der Nachricht / ausbleibende Antwort blockiert Client notify ~> Gelegenheit gespeicherte Nachrichten zu verarbeiten Seite 20
Vom Monitor zum Server: Bemerkung Natürlich kann man es sich auch einfach machen und im Server die Verwaltung der Warteschlangen Synchronisationsmechanismen überlassen. Der Server enthält dann einfach den Monitor und startet für jede Anfrage einen Thread der auf dem Monitor operiert. Beispiel PufferServer: abstract class BufferServer extends Thread { val in: MultiInport[Msg] val out: List[OutPort[Msg]] object BufferMonitor { var item : Int = -999 var empty : Boolean = true val executor: ExecutorService = Executors.newCachedThreadPool() implicit val execcontext = ExecutionContext.fromExecutorService(executor) def put(x: Int) = synchronized { while (!empty) { wait empty = false item = x notifyall() override def run(): Unit = { while (! in.eof) { val (p, msg): (Int, Msg) = in.receive msg match { case GetMsg => Future { val v = BufferMonitor.get() out(p).send(valmsg(v)) Der def get() : Int = synchronized { while (empty) { wait empty=true notifyall() item case PutMsg(v) => Future { BufferMonitor.put(v) out(p).send(okmsg) out.foreach(_.close) Seite 21 Code eines Futures wird von einem Thread im Threadpool des Exceutors ausgeführt.
Notation Syntaktische Schleife Die syntaktische for-scheife wird nicht zur Laufzeit abgearbeitet. Es ist eine Text-verkürzende Notation, die durch eine Expansion des Programmcodes vor der Laufzeit ausgeführt wird. Die Schleife darf nicht zur Laufzeit abgewickelt werden: do Der Schleifenkörper ist keine Anweisungsfolge! for i = 0 to n-1 do Als Anweisungsfolge interpretiert würde die erste receive-anweisung eventuell blockieren und alle andere receive-anweisungen würden ignoriert werden. request[i].receive(...) ~>...... od Gemeint ist eine do-schleife mit einer Reihe nicht-deterministischer von receiveanweisungen, von denen eine (eine der ausführbaren) ausgewählt wird. od do for i = 0 to n-1 do request[0].receive(...) ~> request[1].receive(...) ~> Zur Laufzeit existiert die for-schleife nicht.... request[n-1].receive(...) ~> od od Seite 22
Notation Syntaktische Schleife: Implementierung Syntaktische Schleife ist Sprachelement des Pseudocodes Implementierung durch den Programmierer bei der Umsetzung Pseudocode ~> Code Syntaktische Schleife ist Sprachelement der Programmiersprache Expansion durch Makroprozessor Syntaktische Schleife wird durch äquivalentes dynamisches Sprachelement ersetzt Implementierung durch der Programmierer bei der Umsetzung Pseudocode ~> Code: Einsatz eines dynamischen Sprachkonstrukts Compile-Zeit-Konstante do for i = 0 to n-1 do request[i].receive(...) ~>... od od do Programmierer / Makroprozessor Pseudocode / Code do for i = 0 to n-1 do request[i].receive(...) ~>... od od request[0].receive(...) ~>... request[n-1].receive(...) ~>... od Code? äquivalentes Laufzeit-Konstrukt? Programmierer Pseudocode Seite 23
Notation Syntaktische Schleife: Implementierung durch äquivalente dynamische Sprachelemente Notwendiges Konstrukt: Nichtdeterministisches Empfangen aus einer von n Quellen, mit Identifikation der Quelle Mögliche Konstrukte Empfang über Ports, denen jeweils beliebig viele Kanäle zugeordnet sein können, die Nachrichten müssen ihre Quelle identifizieren semi-dynamische Lösung, wenn die Bindung Port-Kanal zu beginn der Laufzeit fixiert wird. Empfangsoperation mit einer Menge an Kanälen als Argumenten und eine empfangsbereitem Kanal als Resultat Seite 24 (source, msg) = minport.receive() readychannel = receive(channelset)
Notation Syntaktische Schleife: Implementierung mit Multiport class MultiInport[T](inports: List[InPort[T]]) extends InPort[(Int,T)] { object Buffer { var item : T = _ var port : Int = -1 var empty : Boolean = true (source, msg) = minport.receive() def put(p: Int, x: T) = synchronized { while (!empty) { wait empty = false item = x port = p notifyall() val receivers = Range(0, inports.length).map( i => new Thread { override def run() : Unit = { while(! inports(i).eof) { try { Buffer.put(i, inports(i).receive) catch { case e: NoSuchElementException => ) def get() : (Int, T) = synchronized { while (empty) { wait empty=true notifyall() (port, item) receivers.foreach(_.start()) Erlaubt den Datenempfang von irgendeinem Port. Maximal einfache Implementierung: aktive Lösung mit einem Thread pro einfachem Port. def receive: (Int,T) = { return Buffer.get def eof: Boolean = inports.filter(_.eof).length == inports.length Passive Lösungen sind auch möglich aber deutlich aufwendiger. Seite 25
Notation Buffer-Server mit Multiport - Implementierung abstract class Buffer extends Thread { val in: MultiInport[Msg] val out: List[OutPort[Msg]] var place: Int = -99 var empty: Boolean = true var pendingputs: Queue[(Int,Int)] = Queue() // contains (sender, value) pairs var pendinggets: Queue[Int] = Queue() // contains senders override def run(): Unit = { while (! in.eof) { val (p, msg): (Int, Msg) = in.receive msg match { case GetMsg => if(!empty) { out(p).send(valmsg(place)) empty = true processpendingputs() else { pendinggets.enqueue(p) case PutMsg(v) => if(empty) { place = v out(p).send(okmsg) empty = false processpendinggets() else { pendingputs.enqueue((p,v)) out.foreach(_.close) def processpendinggets(): Unit = { if (!pendinggets.isempty ) { val sender: Int = pendinggets.dequeue() out(sender).send(valmsg(place)) empty = true; processpendingputs(); def processpendingputs(): Unit = { if (!pendingputs.isempty) { val (sender, v) = pendingputs.dequeue() out(sender).send(okmsg) place = v empty = false; processpendinggets(); Seite 26
Notation Buffer-Server mit Multiport - Implementierung: Prozessnetz object Buffer_App extends App { val c1a = new ChannelWithEndPoints[Msg] val c1b = new ChannelWithEndPoints[Msg] val c2a = new ChannelWithEndPoints[Msg] val c2b = new ChannelWithEndPoints[Msg] val c3a = new ChannelWithEndPoints[Msg] val c3b = new ChannelWithEndPoints[Msg] // // // // // // p1-> b b -> p1 p2 -> b b -> p2 b -> c c -> b c1a c3a p1 c1b object p1 extends Producer { val out: OutPort[Msg] = c1a.datasink val in: InPort[Msg] = c1b.datasource val data: List[Int] = List(1,3,5,7,9) b p2 object p2 extends Producer { val out: OutPort[Msg] = c2a.datasink val in: InPort[Msg] = c2b.datasource val data: List[Int] = List(2,4,6,8,0) c2a c2b object b extends Buffer { val in = new MultiInport(List(c1a.dataSource, c2a.datasource, c3b.datasource)) val out = List(c1b.dataSink, c2b.datasink, c3a.datasink) object c extends Consumer { val in: InPort[Msg] = c3a.datasource val out: OutPort[Msg] = c3b.datasink b.start() c.start() p1.start() p2.start() Seite 27 c c3b