Zum Inhalt springen

Atomare Operationen & Memory Ordering

Verstehe lock-freie Programmierung mit std::atomic. Beherrsche die Memory Orderings (relaxed, acquire, release, seq_cst), implementiere einen Spinlock mit atomic_flag und lerne das Compare-and-Swap-Muster (CAS) kennen.

Was sind atomare Operationen?

Eine atomare Operation ist unteilbar — sie wird entweder vollständig ausgeführt oder gar nicht, und andere Threads sehen keinen Zwischenzustand. std::atomic bietet atomaren Zugriff auf einen Wert vom Typ T, ohne dass ein Mutex nötig wäre.

Stell dir ein simples ++counter auf einem gewöhnlichen int vor, das sich mehrere Threads teilen. In Wahrheit sind das drei Schritte: laden, erhöhen, speichern. Ein anderer Thread kann sich zwischen diese Schritte schieben und damit ein Data Race (undefiniertes Verhalten) auslösen. Mit std::atomic ist das Erhöhen eine einzige atomare Operation — keine Verschränkung, kein UB.

Atomics gehen aber über bloß "unteilbare Operationen" hinaus. Sie steuern auch das Memory Ordering (die Speicherordnung) — also die Regeln dafür, wann Schreibzugriffe eines Threads für andere Threads sichtbar werden. Und genau hier werden Atomics gleichermaßen mächtig wie subtil.

std::atomic — die Grundlagen

Die gebräuchlichsten atomaren Operationen sind load(), store(), exchange() sowie fetch_add()/fetch_sub(). Du kannst auch die Operatoren ++, --, += usw. verwenden.

atomic_counter.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> counter{0};  // atomic integer, initialized to 0

void increment(int n) {
    for (int i = 0; i < n; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
        // or simply: ++counter;  (uses seq_cst by default)
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment, 100'000);
    }
    for (auto& t : threads) t.join();

    std::cout << "Counter: " << counter.load() << "\n";
    // Always prints 1000000 — no data race, no mutex needed
    return 0;
}

Memory Ordering erklärt

Moderne CPUs und Compiler ordnen Speicheroperationen um, um Performance zu gewinnen. Auf x86 fällt das noch vergleichsweise moderat aus, auf ARM/RISC-V dagegen ist es aggressiv. Die Memory Orderings legen fest, welche Umordnungen Compiler und CPU vornehmen dürfen.

memory_order_seq_cst (Voreinstellung) — Die stärkste Ordnung. Alle Threads sehen sämtliche seq_cst-Operationen in derselben totalen Reihenfolge. Einfach und sicher, kann aber Optimierungen einschränken.

memory_order_acquire — Wird bei Ladeoperationen verwendet. Keine Lese- oder Schreiboperation des aktuellen Threads darf vor dieses Laden umgeordnet werden. Merksatz: "Alles, was ich nach diesem Punkt lese, sieht mindestens das, was der freigebende Thread geschrieben hat."

memory_order_release — Wird bei Speicheroperationen verwendet. Keine Lese- oder Schreiboperation des aktuellen Threads darf hinter dieses Speichern umgeordnet werden. Merksatz: "Alles, was ich vor diesem Punkt geschrieben habe, ist für den erwerbenden Thread sichtbar."

memory_order_acq_rel — Kombiniert acquire und release. Wird bei Read-Modify-Write-Operationen wie compare_exchange verwendet.

memory_order_relaxed — Keinerlei Ordnungsgarantien über die Atomarität hinaus. Die Operation ist atomar, darf aber beliebig umgeordnet werden. Nutze sie nur für unabhängige Zähler oder Statistiken, bei denen die Reihenfolge keine Rolle spielt.

Acquire-Release in der Praxis

Das Acquire-Release-Muster ist das Arbeitspferd der lock-freien Programmierung. Ein Release-Store in einem Thread synchronisiert mit einem Acquire-Load in einem anderen Thread und stellt damit eine Happens-before-Beziehung her.

acquire_release.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <cassert>

std::atomic<bool> data_ready{false};
int payload = 0;  // non-atomic — protected by acquire-release

void producer() {
    payload = 42;  // write payload BEFORE the release store
    data_ready.store(true, std::memory_order_release);
    // release: all writes before this store are visible
    // to a thread that does an acquire load of data_ready
}

void consumer() {
    while (!data_ready.load(std::memory_order_acquire)) {
        // spin — acquire: sees all writes before the release store
    }
    // guaranteed to see payload == 42
    assert(payload == 42);  // never fires
    std::cout << "Payload: " << payload << "\n";
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
    return 0;
}

Das Compare-and-Swap-Muster (CAS)

Compare-and-Swap (compare_exchange_weak/compare_exchange_strong) ist der grundlegende Baustein lock-freier Datenstrukturen. Es prüft atomar, ob der Wert einem erwarteten Wert entspricht, und ersetzt ihn in diesem Fall durch einen gewünschten Wert.

cas_max.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> max_value{0};

void update_max(int new_val) {
    int current = max_value.load(std::memory_order_relaxed);
    // CAS loop: keep trying until we succeed or new_val is not bigger
    while (new_val > current) {
        if (max_value.compare_exchange_weak(
                current,    // expected (updated on failure)
                new_val,    // desired
                std::memory_order_relaxed)) {
            break;  // success
        }
        // On failure, 'current' is updated with the actual value
        // Loop re-checks if new_val > current (maybe another thread won)
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 100; ++i) {
        threads.emplace_back(update_max, i);
    }
    for (auto& t : threads) t.join();

    std::cout << "Max: " << max_value.load() << "\n";  // always 99
    return 0;
}

Spinlock mit std::atomic_flag

std::atomic_flag ist der primitivste atomare Typ — auf jeder Plattform garantiert lock-frei. Er unterstützt nur test_and_set() und clear() und eignet sich damit perfekt für einen einfachen Spinlock.

spinlock.cpp
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>

class SpinLock {
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
    void lock() {
        while (flag_.test_and_set(std::memory_order_acquire)) {
            // spin — consider adding a pause/yield hint:
            // __builtin_ia32_pause() or std::this_thread::yield()
        }
    }
    void unlock() {
        flag_.clear(std::memory_order_release);
    }
};

SpinLock spin;
int shared_data = 0;

void work(int n) {
    for (int i = 0; i < n; ++i) {
        spin.lock();
        ++shared_data;  // protected by spinlock
        spin.unlock();
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(work, 100'000);
    }
    for (auto& t : threads) t.join();
    std::cout << "shared_data: " << shared_data << "\n";
    // prints 400000
    return 0;
}
Stolperfalle

memory_order_relaxed bietet keinerlei Ordnungsgarantien über die Atomarität selbst hinaus. Es zu verwenden, um gemeinsam genutzte nicht-atomare Daten zu schützen (etwa ein Flag, das eine Nutzlast absichert), ist ein Fehler:

// FEHLER: relaxed store/load synchronisiert die Nutzlast nicht
payload = 42;
ready.store(true, memory_order_relaxed);  // könnte VOR payload=42 umgeordnet werden!

// Verbraucher:
while (!ready.load(memory_order_relaxed)) {}
assert(payload == 42);  // KANN auf ARM/POWER FEHLSCHLAGEN

Verwende memory_order_relaxed nur, wenn die atomare Variable für sich allein steht und keinen Zugriff auf andere Daten synchronisieren muss. Für Publish-Consume-Muster nimmst du release/acquire.

Best Practice

Beginne mit memory_order_seq_cst (der Voreinstellung) und wechsle erst dann zu schwächeren Ordnungen, wenn Profiling zeigt, dass es nötig ist. Falsche Memory Orderings erzeugen Fehler, die sich auf x86 nahezu nicht reproduzieren lassen, auf ARM aber zutage treten. Für komplexen gemeinsamen Zustand sind Mutexe die bessere Wahl — Atomics sind für einfache gemeinsame Variablen und den Bau lock-freier Primitive gedacht. Prüfe Annahmen zur Lock-Freiheit immer mit std::atomic::is_lock_free().

Wichtigste Erkenntnisse
  • std::atomic bietet unteilbare Operationen ohne Mutexe — sicher für nebenläufiges Lesen und Schreiben
  • Memory Ordering steuert, wie Schreibzugriffe eines Threads für andere Threads sichtbar werden — es geht dabei nicht nur um Atomarität
  • Acquire-Release ist das zentrale Muster: Der Release-Store veröffentlicht Daten, der Acquire-Load nimmt sie entgegen
  • compare_exchange_weak/strong (CAS) ist der Baustein für lock-freie Algorithmen
  • std::atomic_flag ist der einzige Typ, der auf allen Plattformen garantiert lock-frei ist
  • Bleibe standardmäßig bei seq_cst und schwäche die Ordnung nur ab, wenn Profiling es verlangt — subtile Fehler sind extrem schwer zu debuggen

Quiz — Teste dein Wissen

(25 XP)

1. Was garantiert `memory_order_acquire` bei einer Ladeoperation?

2. Warum wird `compare_exchange_weak` üblicherweise in einer Schleife verwendet?

3. Wann ist der Einsatz von `memory_order_relaxed` sicher?