Μετάβαση στο περιεχόμενο

Atomic Operations & Memory Ordering

Understand lock-free programming with std::atomic. Master memory orderings (relaxed, acquire, release, seq_cst), implement a spinlock with atomic_flag, and learn the compare-and-swap (CAS) pattern.

What Are Atomic Operations?

An atomic operation is indivisible — it completes entirely or not at all, with no intermediate state visible to other threads.std::atomicprovides atomic access to a value of typeTwithout requiring a mutex.

Consider a simple++counteron a regularintshared between threads. This is actually three steps: load, increment, store. Another thread can interleave between these steps, causing a data race (undefined behavior). Withstd::atomic, the increment is a single atomic operation — no interleaving, no UB.

But atomics go deeper than just "indivisible operations." They also control memory ordering — the rules governing when writes by one thread become visible to other threads. This is where atomics get both powerful and subtle.

std::atomic Basics

The most common atomic operations areload(),store(),exchange(), andfetch_add()/fetch_sub(). You can also use operators++,--,+=, etc.

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 Explained

Modern CPUs and compilers reorder memory operations for performance. On x86 this is relatively mild, but on ARM/RISC-V it is aggressive. Memory orderings tell the compiler and CPU which reorderings are allowed.

memory_order_seq_cst (default) — The strongest ordering. All threads see allseq_cstoperations in the same total order. Simple and safe but may limit optimization.

memory_order_acquire — Used on loads. No memory reads/writes in the current thread can be reordered before this load. Think of it as: "everything I read after this point sees at least what the releasing thread wrote."

memory_order_release — Used on stores. No memory reads/writes in the current thread can be reordered after this store. Think of it as: "everything I wrote before this point is visible to the acquiring thread."

memory_order_acq_rel — Combines acquire and release. Used on read-modify-write operations likecompare_exchange.

memory_order_relaxed — No ordering guarantees beyond atomicity. The operation is atomic but can be reordered freely. Only use for independent counters or statistics where ordering doesn't matter.

Acquire-Release in Practice

Το μοτίβο απόκτησης-απελευθέρωσης είναι η δύναμη του προγραμματισμού χωρίς κλείδωμα. Ένας αποθηκευτικός χώρος έκδοσης σε ένα νήμα συγχρονίζεται με ένα απόκτηση φορτίου σε άλλο νήμα, δημιουργώντας μια σχέση που συμβαίνει πριν.

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;
}

Compare-and-Swap (CAS) Pattern

Compare-and-swap (compare_exchange_weak/compare_exchange_strong) is the fundamental building block of lock-free data structures. It atomically checks if the value equals an expected value and, if so, replaces it with a desired value.

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 with std::atomic_flag

std::atomic_flagis the most primitive atomic type — guaranteed lock-free on every platform. It supports onlytest_and_set()andclear(), making it perfect for a simple 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;
}
Παγίδα

memory_order_relaxedprovides no ordering guarantees beyond atomicity itself. Using it to protect shared non-atomic data (like a flag guarding a payload) is a bug:

// BUG: relaxed store/load does not synchronize payload
payload = 42;
ready.store(true, memory_order_relaxed);  // might be reordered BEFORE payload=42!

// Consumer:
while (!ready.load(memory_order_relaxed)) {}
assert(payload == 42);  // MAY FAIL on ARM/POWER

Use memory_order_relaxed only when the atomic variable stands alone and doesn't need to synchronize access to other data. For publish-consume patterns, use release/acquire.

Βέλτιστη πρακτική

Start withmemory_order_seq_cst(the default) and only relax to weaker orderings when profiling proves it necessary. Incorrect memory orderings produce bugs that are nearly impossible to reproduce on x86 but manifest on ARM. Prefer mutexes for complex shared state — atomics are for simple shared variables and building lock-free primitives. Always verify lock-free assumptions withstd::atomic::is_lock_free().

Βασικά συμπεράσματα
  • Τοstd::atomicπαρέχει αδιαίρετες λειτουργίες χωρίς mutexes — ασφαλές για ταυτόχρονη ανάγνωση και εγγραφή
  • Memory ordering controls how writes in one thread become visible to other threads — it is not just about atomicity
  • Acquire-release is the key pattern: release-store publishes data, acquire-load consumes it
  • Τοcompare_exchange_weak/strong(CAS) είναι το δομικό στοιχείο για αλγόριθμους χωρίς κλείδωμα
  • std::atomic_flagis the only type guaranteed lock-free on all platforms
  • Η προεπιλογή είναιseq_cstκαι αποδυναμώνεται μόνο όταν το απαιτεί η δημιουργία προφίλ — τα λεπτά σφάλματα είναι εξαιρετικά δύσκολο να εντοπιστούν

Κουίζ — Δοκιμάστε τις γνώσεις σας

(25 XP)

1. Τι εγγυάται το`memory_order_acquire`σε μια λειτουργία φόρτωσης;

2. Γιατί το`compare_exchange_weak`χρησιμοποιείται συνήθως σε βρόχο;

3. Πότε είναι ασφαλές για χρήση το`memory_order_relaxed`;