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

9 KiB

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):
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.