Skip to content

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::atomic provides atomic access to a value of type T without requiring a mutex.

Consider a simple ++counter on a regular int shared between threads. This is actually three steps: load, increment, store. Another thread can interleave between these steps, causing a data race (undefined behavior). With std::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 are load(), store(), exchange(), and fetch_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 all seq_cst operations 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 like compare_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

The acquire-release pattern is the workhorse of lock-free programming. A release store in one thread synchronizes with an acquire load in another thread, establishing a happens-before relationship.

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_flag is the most primitive atomic type — guaranteed lock-free on every platform. It supports only test_and_set() and clear(), 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;
}
Pitfall

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

```cpp
// 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.

Best Practice

Start with memory_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 with std::atomic::is_lock_free().

Key Takeaways
  • std::atomic provides indivisible operations without mutexes — safe for concurrent reads and writes
  • 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) is the building block for lock-free algorithms
  • std::atomic_flag is the only type guaranteed lock-free on all platforms
  • Default to seq_cst and weaken only when profiling demands it — subtle bugs are extremely hard to debug

Quiz — Test Your Knowledge

(25 XP)

1. What does `memory_order_acquire` guarantee on a load operation?

2. Why is `compare_exchange_weak` typically used in a loop?

3. When is `memory_order_relaxed` safe to use?