Skip to content

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.

116

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

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

117

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

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

118

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

OrderingVertrag
RelaxedNur Atomizität, keine Ordnungsgarantien — gut für Zähler, Statistiken
ReleaseStore: alle vorherigen Schreibvorgänge dürfen nicht nach hinten rutschen
AcquireLoad: keine nachfolgenden Lese-/Schreibvorgänge dürfen nach vorn gezogen werden
AcqRelFür Read-Modify-Write: Acquire beim Laden, Release beim Speichern
SeqCstGlobale 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

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

119

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

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

120

Ö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

0/3

Haken setzen, um deinen Lernfortschritt zu markieren:

This site uses essential cookies for Stripe payments. No tracking cookies.