hwr-notes/Betriebssysteme/Zusammenfassung_IPC.md
2026-04-09 11:24:56 +02:00

112 lines
No EOL
9 KiB
Markdown

# 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.