ECHTZEIT- & BETRIEBSSYSTEME

Prozesse
& Threads

Parallelisierung wirklich verstehen — vom geteilten Adressraum über die Matrixmultiplikation bis zum Amdahlschen Gesetz, mit erklärtem C++-Code, Animationen und Quiz.

Prozess ≠ Thread Speedup S(N) Amdahl · fork() · waitpid() Matrixmultiplikation in C++
8 parallele Einheiten
arbeiten gleichzeitig
los geht's — 14 Kapitel
01

Betriebssysteme & Echtzeit

Bevor wir parallelisieren, klären wir das Umfeld: Ein Allzweck-Betriebssystem (GPOS) und ein Echtzeitbetriebssystem (RTOS) verfolgen gegensätzliche Ziele.

GPOS · ALLZWECK
Durchsatz & Fairness

Steuert Programme und verwaltet Ressourcen (CPU, RAM, Dateisysteme). Fokus auf hohem Datendurchsatz, Multitasking und flüssiger Bedienung — verteilt die CPU „fair".

RTOS · ECHTZEIT
Determinismus & Fristen

Spezialisiert auf Zuverlässigkeit, Vorhersehbarkeit und strikte Einhaltung zeitlicher Fristen. Strikt prioritätsbasiertes Scheduling hält Latenzen minimal.

GPOS = 🚍 Bus

Transportiert viele Leute gleichzeitig — kommt aber mal früher, mal später an.

RTOS = ⏱ Uhrwerk

Jeder Schlag erfolgt exakt zum richtigen Zeitpunkt — vorhersagbar und deterministisch.

MERKMAL
ECHTZEIT (RTOS)
ALLZWECK (GPOS)
{{ row.k }}
{{ row.rt }}
{{ row.gp }}
MERKSATZ

Ein GPOS ist wie ein Bus — es will viele „Fahrgäste" gleichzeitig transportieren, kommt aber mal früher, mal später. Ein RTOS ist wie ein Uhrwerk — jeder Schlag muss exakt zum richtigen Zeitpunkt erfolgen.

02

Durchsatz & Fairness

Durchsatz (Throughput) misst, wie viele Prozesse pro Zeiteinheit fertig werden. Er steigt, wenn der Aufwand für Kontextwechsel sinkt — doch genau hier gibt es einen Zielkonflikt mit der Fairness.

CPU-ZEITACHSE · EINE CPU, VIELE PROZESSE
{{ dSlices }} Zeitscheiben
{{ seg.tag }}
▮ Prozess-Rechenzeit▮ Kontextwechsel-Overhead
← weniger Wechsel · Durchsatz Fairness · mehr Wechsel →
KONTEXTWECHSEL
{{ dSwitches }}
NUTZBARE RECHENZEIT
{{ dUsable }}%
REAKTIONSZEIT
{{ dReaction }}
MERKMAL
FOKUS FAIRNESS
FOKUS DURCHSATZ
{{ row.k }}
{{ row.fair }}
{{ row.thru }}

Ein modernes Betriebssystem sucht die Balance: flüssige Bedienung (Fairness) und schnelle Erledigung von Aufgaben (Durchsatz) zugleich.

03

Prozesse

Ein Prozess ist laut POSIX.1-2024 entweder ein aktiver Prozess oder ein Zombie-Prozess.

§ 3.189 · AKTIVER PROZESS

Ein Adressraum, in dem ein oder mehrere Threads ausgeführt werden — samt der für diese Threads erforderlichen Systemressourcen.

§ 3.426 · ZOMBIE-PROZESS

Die Überreste eines Prozesses nach seiner Beendigung — bevor der Elternprozess seine Statusinformationen verarbeitet hat.

LEBENSZYKLUS EINES KINDPROZESSES
{{ n.label }}
ELTERNPROZESS
parent
{{ zParentAction }}
waitpid() →
← Exit-Status
KINDPROZESS · PID 2041
{{ zChildState }}
{{ zBadge }}

{{ zCaption }}

Deshalb ruft der Elternprozess im matrix_processes-Programm für jedes Kind waitpid() auf: nur so werden Zombies eingesammelt und der Exit-Code geprüft.

04

Threads

Ein Thread ist ein einzelner Kontrollfluss innerhalb eines Prozesses. Mehrere Threads leben im selben Prozess — und teilen sich fast alles.

EIN PROZESS · VIER THREADS · EIN ADRESSRAUM
T1
Stack
+ Register
T2
Stack
+ Register
T3
Stack
+ Register
T4
Stack
+ Register
▼   alle greifen direkt zu   ▼
GETEILTER ADRESSRAUM
Heap (malloc) · globale & statische Variablen · geöffnete Dateien · Matrizen A, B, C
PRIVAT · PRO THREAD
  • Eigene Thread-ID
  • Eigener Stack (automatische / lokale Variablen)
  • Eigene Scheduling-Priorität & -Richtlinie
  • Eigene Register / Programmzähler
GETEILT · ALLE THREADS
  • Heap — über malloc() bereitgestellter Speicher
  • Globale und statische Variablen
  • Geöffnete Dateien & Ressourcen
  • Jede Adresse, die ein Thread ermitteln kann
ACHTUNG

Weil der Speicher geteilt ist, ist Kommunikation extrem schnell (direkter Zugriff) — erfordert aber Synchronisation (Mutex, Semaphore), um Race Conditions zu vermeiden.

05

Threads vs. Prozesse

Der entscheidende Unterschied ist der Adressraum. Threads teilen ihn — Prozesse nicht. Spiel die Animation ab und achte auf die Festplatten-Lesevorgänge.

C = A × B · 512×512 · 4 Einheiten
THREADS 1 Prozess
🖴 Festplatte · A · B
{{ tReadLabel }}
GETEILTER SPEICHER
A · B · C — nur eine Kopie
▲ ▲ ▲ ▲
T1
T2
T3
T4
Festplatten-Lesevorgänge: {{ tReads }}
PROZESSE N Kindprozesse
🖴 Festplatte · A · B
{{ pReadLabel }}
{{ pp.name }}
A·B eigene Kopie
{{ pTempLabel }}
{{ pAssembleLabel }}
Festplatten-Lesevorgänge: {{ pReads }}

{{ vsCaption }}

MERKMAL
THREADS
PROZESSE
{{ row.k }}
{{ row.t }}
{{ row.p }}
06

POSIX & Programm

POSIX.1-2024 (IEEE Std 1003.1) definiert einheitliche Betriebssystem­schnittstellen. Sein Hauptziel: Quellcode-Portabilität.

EINMAL SCHREIBEN — ÜBERALL AUSFÜHREN
main.cpp
POSIX-API:
fork() · waitpid()
kompiliert &
läuft unverändert
Linux
macOS
BSD / Unix
§ 3.291 · PROGRAMM

Eine vorbereitete Abfolge von Anweisungen an das System zur Ausführung einer definierten Aufgabe.

Umfasst u.a. Shell-Skripte, Eingabesprachen (awk, lex, sed) und Hochsprachen wie C++.
POSIX-SYSTEMAUFRUFE IM PROJEKT
fork()erzeugt einen Kindprozess (man 2 fork)
waitpid()wartet auf ein Kind (man 2 waitpid)
getpid()liefert die eigene Prozess-ID
std::exit()beendet den Kindprozess sofort
07

Matrixmultiplikation

Das Rechenproblem des Projekts: C = A × B. Jedes Element von C ist ein Skalarprodukt aus einer Zeile von A und einer Spalte von B.

Cij= k−1Σl=0 Ail·Blj
{{ mStepLabel }}
A
{{ c.v }}
×
B
{{ c.v }}
=
C
{{ c.v }}
C{{ mIJ }} = {{ t.a }}·{{ t.b }}{{ t.sep }} {{ mResult }}
KERNIDEE

Jede Zeile von C hängt nur von A und B ab — nicht von anderen Zeilen. Deshalb lässt sich die Berechnung problemlos zeilenweise auf mehrere Threads oder Prozesse aufteilen.

08

Das pt-edu Projekt

Dasselbe Problem — C = A × B — einmal mit Threads, einmal mit Prozessen. Beide teilen die Zeilen von C auf ihre Arbeiter auf.

pt-edu/ ├── CMakeLists.txt ├── Aufgabenblatt.md ├── common/ │ └── matrix_io.h ├── matrix_generator/ │ └── main.cpp ├── matrix_threads/ │ ├── main.cpp │ └── matrix_multiply.cpp └── matrix_processes/ ├── main.cpp └── matrix_multiply.cpp
THREAD-VARIANTE

A und B werden einmalig geladen. Alle Threads teilen die Daten im Speicher, berechnen je einen Zeilenbereich und schreiben in den gemeinsamen Speicher.

PROZESS-VARIANTE

Der Eltern­prozess forkt N Kinder. Jedes liest A und B selbst, berechnet seinen Zeilenbereich und schreibt eine Temp-Datei. Der Elternprozess setzt C zusammen.

ZEILEN-AUFTEILUNG · C hat {{ pjRows }} Zeilen
Worker:
Zeile {{ band.idx }}
{{ band.tag }}
rowsPer = ⌊{{ pjRows }} / {{ pjN }}⌋ = {{ pjRowsPer }}
letzter Worker: {{ pjLastCount }} Zeilen

{{ pjNote }}

Im Code: rowEnd = (i == numThreads-1) ? A.rows : (i+1)*rowsPerThread — der letzte Arbeiter bekommt immer den Rest.

09

Speedup

Der Speedup misst, wie viel schneller ein Programm mit N parallelen Einheiten läuft — das Verhältnis von serieller zu paralleler Ausführungszeit.

S(N)= T(1) T(N) serielle Zeit ÷ parallele Zeit
GEMESSEN · THREADS · 256×256
0 2 4 6 8 1 2 4 8 Anzahl Threads N ideal S=N gemessen
N=1 · 8,29 ms
1,00×
N=2 · 4,94 ms
1,68×
N=8 · 3,30 ms
2,51×

Der reale Speedup bleibt deutlich unter der idealen Linie S=N. Von 1→2 Threads verdoppelt sich fast die Leistung, von 2→8 kaum noch. Warum? Das erklärt das Amdahlsche Gesetz.

10

Amdahlsches Gesetz

Warum bringt der achte Thread kaum noch etwas? Weil ein Teil jedes Programms seriell bleibt. Amdahl beziffert die Obergrenze: Der parallelisierbare Anteil p entscheidet, wie viel Beschleunigung überhaupt möglich ist — egal wie viele Kerne.

S(N)= 1 (1−p) + p/N
SPEEDUP-KURVE · SCHIEBE DEN PARALLELEN ANTEIL
p = {{ amP }} %
0 2 4 6 8 1 2 4 8 16 Anzahl Einheiten N ideal S = N {{ amAsymLabel }}
0 % · alles seriell99 % · fast alles parallel
PARALLELER ANTEIL
{{ amP }} %
MAX · N → ∞
{{ amMax }}
BEI N = 8
{{ amS8 }}

{{ amCaption }}

MERKSATZ

Nicht die Zahl der Kerne begrenzt den Speedup, sondern der serielle Rest. Bei 90 % parallel ist bei 10× Schluss — selbst mit 1000 Kernen. Deshalb lohnt es sich, den seriellen Anteil zu verkleinern, statt nur Kerne hinzuzufügen.

11

Der Code, erklärt

Vier Kernstücke aus pt-edu. Klapp jedes auf — der Code oben, die Idee in Worten darunter. Thread- und Prozess-Variante teilen sich dieselbe Worker-Logik.

// matrix_multiply.cpp — rechnet die Zeilen [rowStart, rowEnd)
void multiplyRange(const Matrix& A, const Matrix& B,
                   Matrix& C, int rowStart, int rowEnd) {
  for (int i = rowStart; i < rowEnd; ++i)
    for (int j = 0; j < B.cols; ++j) {
      long sum = 0;
      for (int l = 0; l < A.cols; ++l)
        sum += A.at(i, l) * B.at(l, j);
      C.at(i, j) = sum;              // schreibt in gemeinsames C
    }
}

Jeder Arbeiter bekommt nur seinen Bereich [rowStart, rowEnd). Die innerste Schleife bildet das Skalarprodukt aus Zeile i von A und Spalte j von B — genau die Formel aus Kapitel 07. Weil jede Zeile von C unabhängig ist, kommen sich die Arbeiter nie in die Quere.

// main.cpp (Thread-Variante) — starten & einsammeln
int rowsPerThread = A.rows / numThreads;
std::vector<std::thread> pool;

for (int t = 0; t < numThreads; ++t) {
  int start = t * rowsPerThread;
  int end   = (t == numThreads - 1) ? A.rows
                                  : start + rowsPerThread;
  pool.emplace_back(multiplyRange, std::cref(A), std::cref(B),
                    std::ref(C), start, end);
}
for (auto& th : pool) th.join();   // auf alle warten

Alle Threads teilen sich A, B und C im selben Adressraum — deshalb werden sie per std::cref / std::ref als Referenz übergeben, ohne zu kopieren. Der letzte Thread bekommt A.rows als Ende, damit keine Zeile fehlt. join() blockiert, bis jeder Thread fertig ist.

// main.cpp (Prozess-Variante) — Kinder abspalten
for (int p = 0; p < numProcs; ++p) {
  pid_t pid = fork();            // Prozess duplizieren
  if (pid == 0) {                // 0 = wir sind das Kind
    int start = p * rowsPerProc;
    int end   = (p == numProcs - 1) ? A.rows
                                    : start + rowsPerProc;
    computeAndWriteTemp(A, B, start, end, p);
    std::exit(0);                // Kind beendet sich sauber
  }
  children.push_back(pid);         // Eltern merkt sich die PID
}

fork() dupliziert den Prozess: im Kind liefert es 0, im Elternprozess die PID des Kindes. Jedes Kind hat seinen eigenen Adressraum, liest A und B selbst und schreibt sein Teilergebnis in eine Temp-Datei. std::exit(0) beendet das Kind — sonst liefe es in der Schleife des Elternteils weiter und würde selbst forken.

// Eltern: auf alle Kinder warten, Zombies einsammeln
for (pid_t pid : children) {
  int status;
  waitpid(pid, &status, 0);         // blockiert bis Kind endet
  if (WIFEXITED(status) && WEXITSTATUS(status) != 0)
    std::cerr << "Kind " << pid << " fehlgeschlagen";
}
assembleResult(C, numProcs);         // Temp-Dateien -> C

waitpid() holt den Exit-Status jedes Kindes ab — genau der „reaping“-Vorgang aus Kapitel 03. WIFEXITED und WEXITSTATUS prüfen, ob das Kind sauber mit Code 0 endete. Erst danach liest der Elternprozess die Temp-Dateien und setzt C zusammen.

12

Die Aufgaben

Fünf Fragen zum Projekt — erst selbst überlegen, dann die Musterlösung aufklappen.

{{ t.num }}
{{ t.title }} {{ t.tag }}

{{ t.prompt }}

MUSTERLÖSUNG

{{ t.solution }}

13

Fachwortglossar

Die wichtigsten Begriffe EN → DE. Tippe zum Filtern.

{{ glCount }} / {{ glTotal }} Begriffe
{{ g.en }} → {{ g.de }}

{{ g.def }}

Kein Begriff passt zu „{{ glQuery }}“.
14

Quiz

Fünf Fragen quer durch alle Kapitel. Tippe eine Antwort an — richtig oder falsch erscheint sofort, samt Begründung.

{{ quizCorrect }}/{{ quizTotal }}
richtig · {{ quizAnswered }}/{{ quizTotal }} beantwortet
{{ q.num }} {{ q.q }}
{{ q.whyLabel }}
{{ q.why }}
{{ quizResultTitle }}

{{ quizResultText }}