Modul 23 — Atomics & Lock-freie Programmierung
Deep Dive: Atomare Operationen, Memory-Orderings und CAS-Loops — Werkzeuge ohne Schloss, aber mit schärferen Verträgen. Wann Atomics glänzen und wann der Mutex bleibt.
Atomics: Zählen ohne Schloss
Lektion 116 von 5
Willkommen zum Deep Dive
Modul 12 hat dir die Standard-Antwort auf geteilte Daten gezeigt: Mutex +
Arc. Dieses Deep-Dive-Modul bohrt tiefer — in die Schicht darunter. Die
atomaren Typen in std::sync::atomic sind die Werkzeuge, aus denen Mutexe,
Referenzzähler und lock-freie Datenstrukturen selbst gebaut sind. Wer hier
liest, versteht danach, WARUM Arc funktioniert — und wann Atomics schneller
sind als jedes Schloss.
Das Muster bleibt wie in Modul 12: erst Predict (was tut dieses Programm?), dann Run, dann schärfen wir das Verständnis. Starten wir mit dem klassischen Fall: vier Threads, ein Zähler — diesmal ohne Mutex.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
static ZAEHLER: AtomicUsize = AtomicUsize::new(0);
fn main() {
let handles: Vec<_> = (0..4)
.map(|_| {
thread::spawn(|| {
for _ in 0..1000 {
ZAEHLER.fetch_add(1, Ordering::Relaxed);
}
})
})
.collect();
for h in handles {
h.join().unwrap();
}
println!("Zähler: {}", ZAEHLER.load(Ordering::Relaxed));
}
Predict: Was gibt das Programm aus — immer 4000, oder manchmal weniger?
Run: Immer exakt 4000. Der Grund: fetch_add ist atomar — unteilbar.
Das Lesen, Erhöhen und Zurückschreiben passiert als EINE CPU-Instruktion,
in die kein anderer Thread dazwischenfunken kann. Der naive
Gegenspieler zaehler = zaehler + 1 auf einem geteilten Wert ist drei
Schritte (lesen, addieren, schreiben) — zwei Threads dürfen sich darin
überlappen, und Updates gehen verloren (das berüchtigte lost update aus
Modul 12). Genau dieses Fenster schließt fetch_add.
Die Familie im Überblick: load/store lesen und schreiben, swap
tauscht aus und liefert den alten Wert, und fetch_add, fetch_sub,
fetch_and, fetch_or, fetch_xor kombinieren Lesen und Schreiben in
einem Zug. Dazu AtomicBool für Flags und AtomicI64/AtomicU64 für
Zahlen — der Haken an allen: sie verwalten genau EINEN Wert. Keine zwei
Felder, keine Invarianten, kein „zusammengehöriger Zustand".
Was atomar wirklich bedeutet
- Unteilbar: read-modify-write ist eine Operation, kein Dreiklang
- Kein Warten: es gibt kein Schloss, kein Blockieren, keinen Deadlock
- Aber: nur EIN Wert — Beziehungen zwischen mehreren Werten schützt das nicht
- Das
Ordering-Argument (Lektion 118) legt fest, wie viel Sichtbarkeit garantiert ist
✅ Wichtige Erkenntnisse
Haken setzen, um deinen Lernfortschritt zu markieren:
CAS: compare_exchange und die Loop
Lektion 117 von 5
Vergleichen-und-Tauschen: das Herzstück
Die mächtigste atomare Operation heißt CAS — compare and swap. In Rust:
compare_exchange(erwartet, neu, ok_ordering, err_ordering). Sie sagt:
Schreibe neu nur dann, wenn der aktuelle Wert noch erwartet ist — und
melde zurück, ob das geklappt hat. Misslingt es, bekommst du den
tatsächlich vorgefundenen Wert zurück. Daraus baust du das
Grundmuster aller lock-freien Programmierung: die CAS-Loop.
use std::sync::atomic::{AtomicUsize, Ordering};
static STAND: AtomicUsize = AtomicUsize::new(3);
fn verdopple_unter_hundert() -> usize {
let mut aktuell = STAND.load(Ordering::Relaxed);
loop {
if aktuell >= 100 {
return aktuell;
}
match STAND.compare_exchange(
aktuell,
aktuell * 2,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => return aktuell * 2,
Err(gesehen) => aktuell = gesehen,
}
}
}
Predict: Zwei Threads rufen die Funktion gleichzeitig auf, STAND ist 3. Was steht danach in STAND — 6, 12 oder beides nacheinander?
Run: 6 oder 12, je nachdem, wer zuerst cas't. Genau das ist der Punkt:
kein Thread wartet auf den anderen. Der Verlierer der Zwischenzeit wird
beim Err-Zweig mit dem frischen Wert wieder in die Loop geschickt — kein
Schloss, nur erneuter Versuch. Der Err-Fall ist kein Fehler, sondern die
Normale-Ablauf-Anweisung „jemand war schneller, probier es nochmal“.
Dasselbe Muster treibt den kleinsten aller Locks: den Spinlock. Statt zu blockieren dreht er sich im Kreis, bis das Flag kippt:
use std::sync::atomic::{AtomicBool, Ordering};
pub struct Spinlock(AtomicBool);
impl Spinlock {
pub fn new() -> Self {
Spinlock(AtomicBool::new(false))
}
pub fn lock(&self) {
while self
.0
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_err()
{
std::hint::spin_loop(); // aktiv warten — CPU-Zyklen verbrennen
}
}
pub fn unlock(&self) {
self.0.store(false, Ordering::Release);
}
}
Kurzüberblick: das ABA-Problem
CAS prüft „ist der Wert noch A?" — nicht „hat sich seitdem nichts geändert?". Thread A liest Wert A und wird geparkt. Thread B ändert A zu B und dann wieder zurück zu A. Wenn A weiterläuft, gelingt das CAS — obwohl der Zustand sich zwischenzeitlich grundlegend geändert haben kann. Bei simplen Zählern ist das harmlos; bei Zeigern auf wiederverwendete Knoten (Lock- freie Stacks, Queues) führt es zu echten Korruptionen. Die Ökosystem-Lösung sind Epoch-basierte Speicherverwaltung (crossbeam, Lektion 120); in safe Rust mit reinen Zahlen-Atomics begegnet dir ABA praktisch nicht.
CAS-Loop als Denkfigur
- Lies den aktuellen Wert, berechne den Zielwert, cas't ihn
- Bei Err: nimm den zurückgegebenen frischen Wert und beginne neu
- Fortschritt ist garantiert, solange mindestens ein Thread Erfolg hat
- ABA: CAS prüft Werte, nicht Geschichte — bei Zeigern gefährlich, bei Zählern harmlos
✅ Wichtige Erkenntnisse
Haken setzen, um deinen Lernfortschritt zu markieren:
Memory-Orderings: happens-before verstehen
Lektion 118 von 5
Warum es Ordering überhaupt gibt
Sowohl der Compiler als auch die CPU dürfen Anweisungen umordnen — solange das Ergebnis im einzelnen Thread so aussieht, als wäre alles in Programmreihenfolge passiert. Über Threads hinweg gilt das nicht: ohne Vertrag darf Thread B die Schreibvorgänge von Thread A in fast beliebiger Reihenfolge sehen. Genau diesen Vertrag legen die Memory-Orderings fest.
Der wichtigste Begriff ist happens-before: Wenn ein Thread einen Wert
mit Release speichert und ein anderer denselben Wert mit Acquire lädt
und ihn dort sieht, dann sind ALLE Schreibvorgänge, die vor dem Release
standen, auch für den lesenden Thread sichtbar. Release-Acquire ist eine
Einbahnstraße für Sichtbarkeit — gepaart über denselben atomaren Wert.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
static BEREIT: AtomicBool = AtomicBool::new(false);
static DATEN: AtomicUsize = AtomicUsize::new(0);
fn main() {
std::thread::scope(|s| {
s.spawn(|| {
DATEN.store(42, Ordering::Relaxed); // 1) Daten schreiben
BEREIT.store(true, Ordering::Release); // 2) Flag setzen — Zaun
});
s.spawn(|| {
while !BEREIT.load(Ordering::Acquire) {
std::hint::spin_loop();
}
// Acquire passt zum Release: DATEN ist garantiert 42
assert_eq!(DATEN.load(Ordering::Relaxed), 42);
});
});
}
Predict: Würde das auch funktionieren, wenn BEIDE Operationen Relaxed
wären?
Run: Nein — nicht garantiert. Relaxed garantiert nur Atomizität, keine Ordnung. Der lesende Thread könnte das Flag true sehen, aber DATEN noch als 0. Genau hier liegt der Klassiker-Fehler: atomare Flags ohne passende Orderings sind keine Freigabe für die Daten dahinter.
Die fünf Orderings im Überblick
| Ordering | Vertrag |
|---|---|
Relaxed | Nur Atomizität, keine Ordnungsgarantien — gut für Zähler, Statistiken |
Release | Store: alle vorherigen Schreibvorgänge dürfen nicht nach hinten rutschen |
Acquire | Load: keine nachfolgenden Lese-/Schreibvorgänge dürfen nach vorn gezogen werden |
AcqRel | Für Read-Modify-Write: Acquire beim Laden, Release beim Speichern |
SeqCst | Globale totale Ordnung über alle SeqCst-Operationen — teuer, aber denkbar einfach |
Faustregel zum Einstieg: Relaxed für reine Zähler ohne abhängige Daten,
Release/Acquire als Paar für Flag-Übergaben mit Daten dahinter,
SeqCst, wenn du dir unsicher bist oder mehrere Flags kombiniert werden —
Korrektheit schlägt die letzte Mikrosekunde.
Release und Acquire sind ein Paar
- Release sagt: alles davor ist erledigt, wenn ihr das hier seht
- Acquire sagt: wenn ich das hier sehe, sehe ich auch alles davor
- Gepaart über denselben atomaren Wert entsteht happens-before
- AcqRel ist die Kombination für fetch_* und compare_exchange
✅ Wichtige Erkenntnisse
Haken setzen, um deinen Lernfortschritt zu markieren:
Arc + Atomics — und wann Mutex bleibt
Lektion 119 von 5
Atomics hinter Arc: das Stoppsignal
Modul 12 hat Arc als thread-sicheren Besitz eingeführt — mit atomarem
Referenzzähler, wie du jetzt weißt. Die naheliegende Kombination: ein
Arc<AtomicBool> als gemeinsames Stoppsignal, das viele Worker lesen, ein
Controller schreiben kann.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let stopp = Arc::new(AtomicBool::new(false));
let handles: Vec<_> = (0..3)
.map(|_| {
let flag = Arc::clone(&stopp);
thread::spawn(move || {
let mut arbeit = 0u64;
while !flag.load(Ordering::Relaxed) {
arbeit = arbeit.wrapping_add(1); // arbeiten ...
}
arbeit
})
})
.collect();
stopp.store(true, Ordering::Relaxed); // Signal an alle
for h in handles {
h.join().unwrap();
}
}
Predict: Warum muss das Flag in Arc wohnen — das AtomicBool selbst ist doch thread-sicher?
Run: Weil es um Besitz geht, nicht um Atomizität. thread::spawn
braucht 'static-Daten; ein rohes &AtomicBool könnte die erzeugende
Funktion überleben. Arc verteilt gemeinsamen Besitz auf das Flag — die
Atomizität der Operationen ist davon unabhängig. Beide Mechanismen lösen
verschiedene Probleme und stapeln sich deshalb sauber. Das Signal selbst
braucht hier nur Relaxed: Das Stoppen darf auch ein paar Schleifendrehungen
später ankommen — es hängen keine weiteren Daten dahinter.
Wann der Mutex die bessere Wahl bleibt
Lock-freie Programmierung ist kein Endspiel, das man erreichen muss. Der Mutex bleibt die richtige Wahl, wenn ...
- mehrere Felder eine Invariante tragen: zwei Maps, die zusammen konsistent bleiben müssen — Atomic schützt je einen Wert, der Mutex schützt die Beziehung zwischen ihnen,
- I/O im kritischen Abschnitt steht: busy-waiten auf I/O verschwendet Kerne; der Mutex lässt den Thread schlafen,
- Kontention hoch ist: bei vielen streitenden Threads verbrennt ein Spinlock Zyklen, während der Mutex faire Warteschlangen hat,
- Lesbarkeit zählt:
lock().unwrap()versteht jeder; eine fein justierte Ordering-Kombination braucht eine Diskussion.
Atomic schützt einen Wert, Mutex schützt eine Beziehung
- Ein atomarer Wert ohne Invarianten: Atomic ist genug
- Mehrere Werte, die gemeinsam stimmen müssen: Mutex
- Im Zweifel erst Mutex — Atomics sind die Optimierung danach, nicht davor
✅ Wichtige Erkenntnisse
Haken setzen, um deinen Lernfortschritt zu markieren:
Ökosystem-Blick: crossbeam & arc-swap
Lektion 120 von 5
Was das Ökosystem daraus gebaut hat
Die Standard-Atomics sind der Rohstoff; zwei Crates bauen darauf die Werkzeuge, die du in Produktionscode tatsächlich findest:
crossbeam ist DAS Lock-freie-Ökosystem-Paket: Channels mit
select!, deque- und queue-Datenstrukturen ohne globales Schloss, und
darunter eine Epoch-basierte Speicherverwaltung — die praktische Antwort
auf das ABA-Problem aus Lektion 117: Knoten werden erst freigegeben, wenn
sicher kein Leser mehr hineinschaut. Genau der Teil, den man selbst nicht
bauen will.
arc-swap löst einen anderen Alltagsfall: eine Konfiguration, die viele
Threads ständig lesen und die selten von einem Writer getauscht wird.
ArcSwap<T> erlaubt das Ersetzen des Arc-Inhalts ohne Schloss — Leser
arbeiten ungestört weiter mit dem alten Arc, der vom letzten Leser
freigegeben wird. Perfekt für Hot-Reload-Konfigurationen.
Praxis-Szenario: Der Metrik-Zähler unter Last
Dein Team betreibt einen Request-Handler mit 32 Worker-Threads. Der Metrik-Zähler hängt hinter einem Mutex — im Profiling verschwindet 9 % der Zeit im Schloss. Der Vorschlag: lock-freie Umbau mit Atomics. Was tust du?
Was du im Deep Dive mitgenommen hast
- Atomics: unteilbare Einzelwert-Operationen — fetch_add statt lost update
- CAS-Loop: vergleichen, tauschen, bei Err mit frischem Wert neu
- Orderings: Relaxed für Zähler, Release/Acquire für happens-before
- Mutex bleibt, wenn Invarianten, I/O oder Lesbarkeit es verlangen
- ABA kennt, aber in safe Rust mit Zahlen-Atomics kaum betroffen
- crossbeam und arc-swap: das Ökosystem für den produktiven Einsatz
✅ Wichtige Erkenntnisse
Haken setzen, um deinen Lernfortschritt zu markieren: