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.
#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.
#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.
#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.
#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_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.
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.
std::atomicprovides 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 algorithmsstd::atomic_flagis the only type guaranteed lock-free on all platforms- Default to
seq_cstand 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?