# Zusammenfassung: IPC ## 1. Grundlagen und Motivation Prozesse benötigen häufig einen Informationsaustausch (Beispiel: Ein E-Mail-Programm Mail User Agent sendet Daten an den Mail Transfer Agent). Da jeder Prozess grundsätzlich einen eigenen, isolierten Adressraum besitzt, ist eine direkte Kommunikation nicht trivial. **Das Grundprinzip:** - Für die Kommunikation ist gemeinsam genutzter Speicher (Shared Memory) zwingend erforderlich. Dies kann ein Bereich im Kernel-Adressraum sein oder eine Datei auf der Festplatte. - Moderne Betriebssysteme erlauben es Prozessen, Teile ihres Adressraums explizit für andere freizugeben. **Prozesse vs. Threads in der IPC:** - Threads desselben Prozesses teilen sich bereits automatisch den Adressraum. - Wenn zwei Prozesse einen gemeinsamen Speicherbereich nutzen, verhalten sie sich technisch fast identisch zu zwei Threads. - Daher gilt der Leitsatz: **Interprozesskommunikation ist im Grunde immer Interthreadkommunikation.** ## 2. Das Problem: Race-Conditions (Wettlaufsituationen) **Definition:** Eine Race-Condition entsteht, wenn mehrere Prozesse/Threads gleichzeitig auf gemeinsam beschreibbare Ressourcen zugreifen und das Endergebnis von der zufälligen zeitlichen Reihenfolge der Ausführung abhängt. **Gefahren:** - Race-Conditions führen zu semantischen Fehlern, die schwer zu finden sind (keine Syntaxfehler!). - Der Code funktioniert meistens korrekt, stürzt aber sporadisch ab („Heisenbugs"). ### Beispiel 1: Das Logdatei-Problem (Der Absturz) Zwei Prozesse (A und B) wollen in dieselbe Logdatei schreiben: 1. Prozess A liest den Status: „Datei ist geschlossen". A setzt intern `is_open = false`. 2. **Kontextwechsel:** Die Zeitscheibe von A läuft ab, bevor A die Datei wirklich öffnen kann. 3. Prozess B kommt an die Reihe, liest den Status: „Datei ist geschlossen". 4. Prozess B öffnet die Datei, schreibt hinein und setzt den Status (physikalisch) auf „offen". 5. **Kontextwechsel:** B wird unterbrochen. 6. Prozess A läuft weiter. Da A intern gespeichert hat `is_open = false`, prüft es nicht erneut. 7. **Fehler:** A versucht, die (bereits offene) Datei erneut zu öffnen → Absturz oder Datenverlust. ### Beispiel 2: Lost Update (Datenbank) Zwei Threads wollen einen Zähler (`counter = 0`) erhöhen. Code: `counter = counter + 1;` Obwohl der Code logisch aussieht, passiert auf Maschinenebene Folgendes: 1. Thread A lädt Wert 0 in Register. 2. Thread B lädt Wert 0 in Register (bevor A schreibt). 3. A erhöht auf 1 und speichert. 4. B erhöht (seine 0) auf 1 und speichert. → Ergebnis ist 1 statt 2. Ein Update ging verloren. ## 3. Die Lösung: Der Kritische Bereich **Definition:** Der Programmteil, in dem auf den gemeinsam genutzten Speicher zugegriffen wird, nennt sich **Kritischer Bereich (Critical Section)**. ### Die 4 zentralen Bedingungen für Synchronisation Um Race-Conditions zu verhindern, muss das Betriebssystem folgende Regeln garantieren: 1. **Gegenseitiger Ausschluss (Mutual Exclusion):** Es dürfen sich niemals zwei Prozesse gleichzeitig im kritischen Bereich befinden. 2. **Keine Annahmen:** Die Lösung darf nicht von der Geschwindigkeit der Prozesse oder der Anzahl der CPUs abhängen. 3. **Keine Blockierung von außen:** Ein Prozess, der sich außerhalb seines kritischen Bereichs befindet, darf andere Prozesse nicht blockieren. 4. **Kein ewiges Warten (No Starvation):** Jeder Prozess muss irgendwann die Chance bekommen, in den kritischen Bereich einzutreten. ## 4. Mechanismus 1: Atomare Operationen Das Grundproblem ist, dass Hochsprachen-Befehle (wie `i++`) nicht atomar (unteilbar) sind. Sie bestehen aus mehreren CPU-Befehlen (Load, Increment, Store), die unterbrochen werden können. **Lösung auf Hardware-Ebene:** Prozessoren bieten spezielle Assembler-Instruktionen, die garantiert nicht unterbrochen werden können. Wären alle Befehle atomar, gäbe es keine Race-Conditions. **Wichtige Assembler-Befehle:** - **TSL (Test-and-Set-Lock):** Liest ein Byte aus dem Speicher in ein Register und schreibt im selben Taktzyklus einen neuen Wert (z.B. 1) an diese Stelle. - **FAA (Fetch-and-Add):** Inkrementiert einen Wert im Speicher atomar. - **CAS (Compare-and-Swap):** Vergleicht den Speicherinhalt mit einem Erwartungswert und tauscht ihn nur bei Übereinstimmung aus (Basis für lock-free Algorithmen). - **XCHG (Exchange):** Tauscht die Inhalte zweier Register oder Speicheradressen atomar. ## 5. Mechanismus 2: Mutex (Mutual Exclusion) **Konzept:** Ein Mutex ist eine Technik und Datenstruktur, die als "Schloss" dient. - Nur der Thread, der den Mutex "besitzt" (lock), darf in den kritischen Bereich. - Nur der Besitzer kann den Mutex wieder freigeben (unlock). **Ablauf (Code-Schema):** ```c pthread_mutex_lock(&m); // Versuche Schloss zu holen. Wenn belegt -> Warten. /* KRITISCHER BEREICH (z.B. Schreiben in Datei) */ pthread_mutex_unlock(&m); // Schloss freigeben. ``` ### Verhalten bei belegtem Mutex Wenn Thread A den Mutex hat und Thread B ihn will, hat B drei Optionen: 1. **Passives Warten:** B gibt die CPU ab und wird vom OS "schlafen gelegt" (blocked), bis der Mutex frei ist. 2. **Aktives Warten (Spinlock):** B läuft in einer Schleife und prüft permanent („Ist frei? Ist frei?"). Sinnvoll nur bei sehr kurzen Wartezeiten auf Multi-Core-Systemen. 3. **Timeout:** B bricht den Versuch nach einer Zeit ab. ## 6. Mechanismus 3: Signale **Eigenschaften:** Signale sind ein sehr altes Konzept (Unix) und stellen eine Software-Unterbrechung (Interrupt) dar. Sie können an Prozesse oder Threads gesendet werden. **Funktionsweise:** - Wenn ein Signal empfangen wird, unterbricht der Prozess seine Arbeit sofort. - Entweder führt er eine ISR (Interrupt Service Routine) aus (benutzerdefiniert) oder die Standardaktion des Kernels (z.B. Prozess beenden). - **Signalmaske:** Jeder Thread kann definieren, welche Signale er blockieren/ignorieren möchte. **Wichtige POSIX-Funktionen:** - `pthread_kill()`: Sendet Signal an einen Thread (nicht zwingend tödlich, dient als Benachrichtigung). - `sigwait()`: Thread wartet passiv auf ein bestimmtes Signal. ## 7. Mechanismus 4: Semaphore (nach Dijkstra) **Konzept:** Ein Semaphor ist mächtiger als ein Mutex. Er erlaubt den Zugriff einer vordefinierten Anzahl von Threads gleichzeitig. ### Datenstruktur Ein Semaphor besteht aus zwei Elementen: 1. Einem Zähler (Integer). 2. Einer Warteschlange (Queue) für blockierte Threads. ### Die zwei Operationen - **P() / wait():** Dekrementiert den Zähler. - Ist der Zähler danach ≥ 0: Zugriff erlaubt. - Ist der Zähler < 0 (bzw. war 0): Thread wird blockiert und in die Queue geschoben. - **V() / signal():** Inkrementiert den Zähler. - Wenn Threads in der Queue warten, wird einer geweckt. ### Unterschied zu Mutex - Ein Mutex muss vom Besitzer freigegeben werden. Ein Semaphor kann von jedem Thread inkrementiert (V) werden (Asynchrone Signalisierung). - Ein Semaphor mit Zähler = 1 (Binärer Semaphor) verhält sich wie ein Mutex. ### Beispiel: Bahnhofsüberwachung (Performance-Begrenzung) **Szenario:** Es gibt viele Kameras, aber aus Performance-Gründen dürfen maximal 3 Such-Threads gleichzeitig Bilder analysieren. - **Initialisierung:** `Semaphore s = new Semaphore(3);` - **Thread startet:** `s.P()` (Zähler wird 2... dann 1... dann 0). - Der 4. Thread muss warten (Zähler bleibt 0, Thread in Queue). - **Thread fertig:** `s.V()` (Zähler wird 1, Wartender darf rein). ## 8. Mechanismus 5: Monitore **Konzept:** Monitore sind ein Konstrukt auf höherer Abstraktionsebene (Sprachunterstützung). Ein Monitor ist ein gekapselter kritischer Bereich (z.B. eine Klasse oder Methode). **Eigenschaften:** - **Automatische Sicherheit:** Der Compiler garantiert, dass immer nur ein einziger Prozess/Thread gleichzeitig im Monitor aktiv ist. - Programmierer müssen Lock/Unlock nicht manuell schreiben → weniger fehleranfällig. **Implementierung in Java:** - Schlüsselwort: `synchronized`. - Kann auf Methoden (`synchronized void methode()`) oder Blöcke (`synchronized(this) { ... }`) angewendet werden. ### Bedingte Synchronisation (Warten im Monitor) Oft muss ein Thread im Monitor warten, bis eine Bedingung erfüllt ist (z.B. Puffer nicht mehr leer). Dazu gibt es spezielle Methoden: - `wait()`: Thread legt sich schlafen und gibt den Monitor frei, damit andere eintreten können. - `notify()`: Weckt einen wartenden Thread (zufällig). - `notifyAll()`: Weckt alle wartenden Threads (sicherer, um Deadlocks zu vermeiden). ## 9. Weitere Konzepte ### Das volatile Primitiv - Kennzeichnet Variablen, die sich "außerhalb des Programmflusses" ändern können (z.B. durch Hardware oder andere Threads). - **Effekt:** Der Compiler schaltet Optimierungen ab und erzwingt bei jedem Zugriff ein Lesen aus dem Hauptspeicher (statt aus dem schnellen CPU-Cache). - **Achtung:** `volatile` garantiert Sichtbarkeit (Aktualität), aber keine Atomizität. Es ersetzt keinen Mutex bei komplexen Operationen. ### Parallele Programmiersprachen Sprachen wie Occam, Ada oder Erlang besitzen eingebaute Kontrollstrukturen (z.B. `PAR` für parallele Ausführung oder Kanäle für Kommunikation), um Synchronisation direkt in der Syntax abzubilden, statt externe Bibliotheken zu nutzen.