Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 16 - Hardware-Sicht: Concurrency unter der Lupe von CPU, RAM und Kernel

Hallo Thorsten! Nachdem wir uns mit den Abstraktionen der Threadsicherheit und asynchronen Runtimes beschäftigt haben, reißen wir jetzt die Motorhaube auf und analysieren, wie Nebenläufigkeit physikalisch auf CPU- und Speicher-Ebene abgebildet wird.

Als Systemprogrammierer gibst du dich nicht mit der Erklärung „Es läuft gleichzeitig“ zufrieden. Du willst wissen: Wie sieht der Context Switch im CPU-Kern aus? Wie verhalten sich L1- und L2-Caches bei geteiltem Speicher? Und wie werden asynchrone Futures hardwareseitig abgebildet?

Schnapp dir einen Kaffee – wir steigen tief ab!


1. Was passiert bei thread::spawn auf Betriebssystem-Ebene?

Wenn du thread::spawn aufrufst, delegiert Rust diese Aufgabe direkt an das Betriebssystem (unter Linux wird der Systemaufruf clone mit entsprechenden Flags verwendet).

Die physikalischen Kosten eines OS-Threads:

  1. Stack-Allokation: Das Betriebssystem reserviert für den neuen Thread einen eigenen Speicherbereich auf dem Stack (standardmäßig meist 2 Megabyte unter Linux). Dieser Speicher muss im virtuellen Adressraum verwaltet werden.
  2. Context Switch (Kontextwechsel): Wenn die CPU zwischen Threads hin- und herspaltet, muss der aktuelle Zustand des CPU-Kerns gesichert werden:
    • Die CPU-Register (Program Counter, Stack Pointer, allgemeine Register) werden in den Speicher geschrieben.
    • Die Page Tables (Speicherübersetzungstabellen) des MMU (Memory Management Unit) müssen eventuell getauscht werden.
    • Der Instruction- und Data-Cache des CPU-Kerns ist für den neuen Thread ungültig, was anfangs zu massiven Cache-Misses führt.
  3. Scheduler-Overhead: Der Betriebssystem-Scheduler muss ständig Berechnungen anstellen, welcher Thread als Nächstes CPU-Zeit erhält.

Systemprogrammierer-Regel: Erstelle OS-Threads niemals in einer engen Schleife für kleine Aufgaben. Nutze stattdessen Thread-Pools (z. B. das Crate rayon) oder asynchrone Programmierung.


2. Cache-Kohärenz und das MESI-Protokoll

Moderne Mehrkern-CPUs besitzen pro Kern extrem schnelle L1- und L2-Caches. Wenn zwei verschiedene CPU-Kerne dieselbe Variable aus dem RAM lesen, kopieren sie diese in ihre jeweiligen Caches.

Das Problem: Cache Line Bouncing (False Sharing)

Wenn Kern 1 die Variable ändert, muss er diese Änderung an Kern 2 signalisieren, da Kern 2 sonst veraltete Daten lesen würde.

Die CPUs nutzen hierfür das MESI-Protokoll (Modified, Exclusive, Shared, Invalid):

  1. Wenn Kern 1 den Wert ändert, markiert er die entsprechende Cache Line (meist 64 Bytes groß) in seinem Cache als Modified.
  2. Gleichzeitig schickt er ein Signal über den internen Prozessorbus, um diese Cache Line im Cache von Kern 2 als Invalid zu markieren.
  3. Möchte Kern 2 nun wieder auf die Variable zugreifen, bemerkt er den Invalid-Zustand. Die CPU muss den Befehl anhalten und die Daten mühsam aus dem L3-Cache oder dem Hauptspeicher nachladen (Cache Line Bouncing).

Wenn zwei Threads auf unterschiedlichen Kernen ständig dieselbe Speicheradresse oder benachbarte Speicheradressen in derselben Cache Line ändern, bricht die Performance dramatisch ein.


3. Wie Atomics auf CPU-Ebene funktionieren

Atomare Typen (AtomicUsize etc.) verzichten auf Mutexes und Betriebssystem-Sperren. Sie kommunizieren direkt mit der Hardware.

1. Compare-And-Swap (CAS)

Auf x86-Prozessoren kompiliert eine atomare Operation oft zu dem Befehl LOCK CMPXCHG. Das Präfix LOCK signalisiert dem Speicherbus der CPU: „Reserviere den Zugriff auf diese Speicheradresse exklusiv für meinen Kern für die Dauer dieses Befehls.“ Kein anderer Kern darf während dieses CPU-Takts auf diese Adresse zugreifen.

2. Speicherbarrieren (Memory Barriers / Fences)

Moderne CPUs führen Befehle zur Performance-Steigerung nicht immer in der geschriebenen Reihenfolge aus (Out-of-Order Execution).

Speicherordnungen (wie Ordering::SeqCst oder Ordering::Acquire/Release) zwingen die CPU, sogenannte Memory Barriers (Speicherbarrieren) in den Befehlsstrom einzufügen. Diese Barrieren verhindern, dass Lese- oder Schreibbefehle vor oder hinter die Barriere rutschen. Das sorgt für korrekte Programmabläufe, bremst aber die Out-of-Order-Optimierung der CPU aus.


4. Die Hardware-Sicht auf Async Rust (Futures als State Machines)

Im Gegensatz zu Green Threads in Sprachen wie Go oder Java, die ebenfalls einen eigenen Stack pro Task allokieren, arbeitet Async Rust stackless (stacklos).

Wie funktioniert das?

Wenn Sie eine asynchrone Funktion schreiben, übersetzt der Rust-Compiler diese in einen statischen Zustandsautomaten (eine automatisch generierte Struktur).

#![allow(unused)]
fn main() {
// Quellcode:
async fn beispiel() {
    schritt_1().await;
    schritt_2().await;
}
}

Der Compiler macht daraus eine Struktur, die ungefähr so aussieht:

#![allow(unused)]
fn main() {
enum BeispielFutureState {
    Start,
    WarteAufSchritt1(Schritt1Future),
    WarteAufSchritt2(Schritt2Future),
    Fertig,
}
}

Die Hardware-Vorteile:

  1. Kein Stack-Overhead: Ein Future benötigt keinen reservierten Stack-Speicher. Es ist so groß wie der größte Zustand im Zustandsautomaten.
  2. Keine Heap-Allokationen: Futures können auf dem Stack der übergeordneten Funktion leben oder in einem einzigen großen Block (z. B. im Task-Queue der Runtime) allokiert werden.
  3. Hocheffizienter Context Switch: Wenn ein Future Poll::Pending zurückgibt, speichert die Runtime nur den winzigen Enum-Zustand. Der Wechsel zum nächsten Task ist ein einfacher Funktionsaufruf (kein Tausch von CPU-Registern oder Page Tables nötig).