Memory Order — Memory Ordering, Atomics, Fence Semantics
In this tutorial, you will learn about Memory Order. We cover key concepts, practical examples, and best practices to help you master this topic.
C++ atomic operations with memory ordering parameters establish inter-thread synchronization, from sequentially consistent ordering to relaxed semantics, providing the foundation for lock-free data structures.
What You'll Learn
You will use std::atomic<T> for lock-free operations on shared data, understand the six memory ordering modes (memory_order_seq_cst, acquire, release, acq_rel, consume, relaxed), apply acquire-release semantics for correct synchronization, use atomic fences for standalone ordering, and avoid common pitfalls like data races and torn reads.
Why It Matters
Modern CPUs reorder memory operations for performance. Without correct memory ordering, concurrent code can see stale values, read partial updates, or observe operations in inconsistent orders. C++ atomics with memory ordering give you portable control over synchronization that maps to the correct CPU instructions on every architecture (x86, ARM, RISC-V).
Learning Path
graph LR
A["27: Object Lifetimes"] --> B["28: Memory Order"]
B --> C["29: STL Overview"]
C --> D["30: Vector"]
style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
style C fill:#4a90d9,stroke:#2c5f8a,color:#fff
style D fill:#4a90d9,stroke:#2c5f8a,color:#fff
Atomic Basics
#include <iostream>
#include <atomic>
#include <thread>
int main() {
std::atomic<int> counter(0);
std::thread t1([&]() {
for (int i = 0; i < 10000; ++i) counter.fetch_add(1);
});
std::thread t2([&]() {
for (int i = 0; i < 10000; ++i) counter.fetch_add(1);
});
t1.join();
t2.join();
std::cout << counter.load() << "\n"; // 20000 (guaranteed)
}
Without std::atomic<int>, the ++ operation would be three separate instructions (read, modify, write), and two threads could interleave, losing updates.
Memory Order Overview
#include <iostream>
#include <atomic>
#include <thread>
std::atomic<int> data(0);
std::atomic<bool> ready(false);
void producer() {
data.store(42, std::memory_order_release);
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {
// spin
}
std::cout << data.load(std::memory_order_acquire) << "\n"; // guaranteed 42
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
The six memory orders, from strongest to weakest:
| Order | Description |
|---|---|
memory_order_seq_cst |
Sequential consistency (default) |
memory_order_acq_rel |
Acquire-release for read-modify-write |
memory_order_acquire |
Prevents reordering after the load |
memory_order_release |
Prevents reordering before the store |
memory_order_consume |
Data-dependent ordering (use acquire instead) |
memory_order_relaxed |
No ordering constraints |
Sequential Consistency
#include <atomic>
#include <thread>
std::atomic<bool> x(false), y(false);
std::atomic<int> z(0);
void writeX() {
x.store(true, std::memory_order_seq_cst);
}
void writeY() {
y.store(true, std::memory_order_seq_cst);
}
void readXthenY() {
while (!x.load(std::memory_order_seq_cst)) {}
if (y.load(std::memory_order_seq_cst)) ++z;
}
void readYthenX() {
while (!y.load(std::memory_order_seq_cst)) {}
if (x.load(std::memory_order_seq_cst)) ++z;
}
int main() {
std::thread t1(writeX);
std::thread t2(writeY);
std::thread t3(readXthenY);
std::thread t4(readYthenX);
t1.join(); t2.join(); t3.join(); t4.join();
// With seq_cst, z must be 1 or 2 (never 0)
// With relaxed, z could be 0
}
Sequential consistency provides a single total order of all sequentially-consistent operations, as if all threads executed in some interleaving. This is the default and easiest to reason about, but has the highest cost.
Acquire-Release Semantics
#include <atomic>
#include <thread>
#include <cassert>
std::atomic<int> guard(0);
int payload = 0;
void producer() {
payload = 42; // A
guard.store(1, std::memory_order_release); // B
}
void consumer() {
while (guard.load(std::memory_order_acquire) == 0) {} // C
assert(payload == 42); // guaranteed: C synchronizes with B
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
- Release (in producer): all writes before the store are visible to the thread that acquires
- Acquire (in consumer): all reads after the load see the values written before release
This establishes a happens-before relationship between the release and acquire operations.
Relaxed Ordering
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter(0);
void worker() {
for (int i = 0; i < 1000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::thread threads[10];
for (auto& t : threads) {
t = std::thread(worker);
}
for (auto& t : threads) {
t.join();
}
std::cout << counter.load() << "\n"; // 10000 (atomic, but no ordering)
}
Relaxed operations are atomic (no torn reads) but provide no synchronization or ordering. Use them for counters that do not guard other data.
Read-Modify-Write Operations
#include <atomic>
#include <iostream>
int main() {
std::atomic<int> value(10);
// fetch_add: return old value, add
int old = value.fetch_add(5);
std::cout << old << " " << value.load() << "\n"; // 10 15
// exchange: set new, return old
int prev = value.exchange(20);
std::cout << prev << " " << value.load() << "\n"; // 15 20
// compare_exchange_weak: CAS loop
int expected = 20;
bool success = value.compare_exchange_weak(expected, 30);
std::cout << success << " " << value.load() << "\n"; // 1 30
// compare_exchange_strong: no spurious failure
expected = 30;
success = value.compare_exchange_strong(expected, 40);
std::cout << success << " " << value.load() << "\n"; // 1 40
}
Atomic Fences
#include <atomic>
#include <thread>
std::atomic<bool> flag(false);
int data = 0;
void producer() {
data = 42;
std::atomic_thread_fence(std::memory_order_release);
flag.store(true, std::memory_order_relaxed);
}
void consumer() {
while (!flag.load(std::memory_order_relaxed)) {}
std::atomic_thread_fence(std::memory_order_acquire);
assert(data == 42); // guaranteed by fence pairing
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
}
Fences establish ordering without being tied to a specific atomic variable. They are more flexible but complex.
Performance Considerations
On x86, all stores are release stores and all loads are acquire loads (except for movnti). This means:
memory_order_seq_cstrequiresmfenceorlockprefix (most expensive)memory_order_acquireandmemory_order_releaseare free on x86memory_order_relaxedavoids any CPU barriers
On ARM and RISC-V, every memory order below seq_cst maps to specific barrier instructions, making the choice of ordering more impactful.
Common Mistakes
Mistake 1: Using volatile for Thread Synchronization
volatile int flag = 0; // WRONG for threading
volatile does not provide atomicity or memory ordering. Use std::atomic<int>.
Mistake 2: Assuming relaxed Atomic is Useless
Relaxed is fine for counters, statistics, and flags that do not guard other data.
Mistake 3: Forgetting That load() and store() Default to seq_cst
flag.store(true); // equivalent to memory_order_seq_cst (most expensive)
Specify relaxed when full ordering is not needed.
Mistake 4: Data Race on Non-Atomic Data
int shared = 0;
std::atomic<bool> ready(false);
// Thread 1: shared = 42; ready.store(true, memory_order_release);
// Thread 2: while(!ready.load(memory_order_acquire)) {}; assert(shared == 42);
This is correct! The acquire-release pair synchronizes the non-atomic shared access.
Mistake 5: ABA Problem in Lock-Free Structures
When using compare_exchange_weak, a value can change from A to B and back to A between reads. Use version counters or hazard pointers.
Mistake 6: Overusing Sequential Consistency
counter.fetch_add(1); // default seq_cst is overkill for a counter
Use memory_order_relaxed for simple counters.
Practice Questions
- What is the difference between
memory_order_relaxedandmemory_order_seq_cst? - What does acquire-release ordering guarantee?
- Write a lock-free thread-safe counter using
fetch_addwithmemory_order_relaxed. - When would you use a fence instead of an atomic operation with ordering?
- Why does x86 have relatively cheap acquire-release semantics?
Challenge
Implement a simple spinlock mutex using std::atomic<bool> with test_and_set (or exchange). Ensure correct memory ordering. Compare its performance with std::mutex in a contended scenario.
FAQ
Mini Project
Build a lock-free ring buffer for single producer, single consumer:
#include <iostream>
#include <atomic>
#include <thread>
#include <vector>
template <typename T, size_t Capacity>
class SPSCRingBuffer {
private:
static constexpr size_t Mask = Capacity - 1;
T buffer_[Capacity];
std::atomic<size_t> head_{0};
std::atomic<size_t> tail_{0};
public:
bool push(const T& value) {
size_t tail = tail_.load(std::memory_order_relaxed);
size_t next = (tail + 1) & Mask;
if (next == head_.load(std::memory_order_acquire)) {
return false; // full
}
buffer_[tail] = value;
tail_.store(next, std::memory_order_release);
return true;
}
bool pop(T& value) {
size_t head = head_.load(std::memory_order_relaxed);
if (head == tail_.load(std::memory_order_acquire)) {
return false; // empty
}
value = buffer_[head];
head_.store((head + 1) & Mask, std::memory_order_release);
return true;
}
};
int main() {
SPSCRingBuffer<int, 256> buffer;
constexpr int NumItems = 10000;
std::thread producer([&]() {
for (int i = 0; i < NumItems; ++i) {
while (!buffer.push(i)) {}
}
});
std::thread consumer([&]() {
std::vector<int> received;
for (int i = 0; i < NumItems; ++i) {
int value;
while (!buffer.pop(value)) {}
received.push_back(value);
}
std::cout << "Received " << received.size() << " items\n";
});
producer.join();
consumer.join();
}
What's Next
Memory ordering is the foundation of lock-free programming. The next lesson begins Module 4 on STL Containers, starting with an overview of containers, iterators, and algorithm complexity.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro