Memory Orderings
Memory Orderings: Relaxed, Acquire/Release, Sequentially Consistent
Section titled “Memory Orderings: Relaxed, Acquire/Release, Sequentially Consistent”This section covers the std::memory_order enum values, relaxed ordering, acquire/release Semantics, sequentially consistent ordering, a producer-consumer example with acquire/release, a Cross-architecture comparison table, and fence operations.
memory_order Enum
Section titled “memory_order Enum”The std::memory_order enum [N4950 §31.7.5] defines six values:
| Value | Constant | Ordering Guarantee |
|---|---|---|
memory_order_relaxed | No ordering | |
memory_order_consume | dependency-ordered | Data dependency on loaded value |
memory_order_acquire | acquire | No reads/writes after can be reordered before |
memory_order_release | release | No reads/writes before can be reordered after |
memory_order_acq_rel | acquire + release | Both acquire and release |
memory_order_seq_cst | sequential consistency | Total order across all seq_cst operations |
The Memory Model Hierarchy
Section titled “The Memory Model Hierarchy”Memory orderings form a strict hierarchy from weakest to strongest:
\mathrm{relaxed \subset \mathrm{consume \subset \mathrm{acquire \subset \mathrm{acq\_rel \subset \mathrm{seq\_cst \mathrm{relaxed \subset \mathrm{release \subset \mathrm{acq\_rel \subset \mathrm{seq\_cstUsing a stronger ordering than necessary is always safe but may incur performance penalties. Using a Weaker ordering than required results in undefined behavior.
Relaxed Ordering
Section titled “Relaxed Ordering”Relaxed atomics guarantee atomicity only: the operation is indivisible, but there are no Ordering constraints with respect to other memory operations.
\mathrm{relaxed: \mathrm{atomicity \wedge \neg\mathrm{ordering
Use cases:
- Counters and statistics where the exact order of increments doesn”t matter.
- Progress indicators where approximate values are acceptable.
#include <iostream>#include <atomic>#include <thread>#include <vector>
int main() { std::atomic<int> relaxed_counter{0};
std::vector<std::jthread> threads; for (int i = 0; i < 4; ++i) { threads.emplace_back([&relaxed_counter] { for (int j = 0; j < 1'000'000; ++j) { relaxed_counter.fetch_add(1, std::memory_order_relaxed); } }); }
std::cout << "Relaxed counter: " << relaxed_counter.load(std::memory_order_relaxed) << "\n"; return 0;}:::tip Tip Loads and stores compile to plain mov instructions. On ARM, relaxed loads use ldar and stores Use stlr (or ldr/str with relaxed semantics depending on the ARM version). :::
When Relaxed Is Insufficient: The Message Passing Idiom
Section titled “When Relaxed Is Insufficient: The Message Passing Idiom”Relaxed atomics are insufficient when one thread writes data and another thread reads it based on a Flag. Without ordering constraints, the reader may see the flag set but read stale data:
#include <atomic>#include <iostream>
std::atomic<bool> flag{false};int data = 0;
void writer() { data = 42; flag.store(true, std::memory_order_relaxed); // RISK: compiler or CPU may reorder the store to flag before the store to data}
void reader() { while (!flag.load(std::memory_order_relaxed)) {} // RISK: data may still be 0 here — the store to data may not be visible std::cout << "data = " << data << "\n";}// This code has a data race on non-atomic 'data' and is undefined behavior.Acquire/Release Ordering
Section titled “Acquire/Release Ordering”Acquire semantics [N4950 §31.7.5] prevent memory operations after the atomic operation from Being reordered before it:
\mathrm{acquire: \forall\, w \mathrm{ after \mathrm{load: w \nrightarrow \mathrm{before load
Release semantics prevent memory operations before the atomic operation from being reordered after it:
\mathrm{release: \forall\, r \mathrm{ before \mathrm{store: r \nrightarrow \mathrm{after store
When a release store synchronizes-with an acquire load, a synchronizes-with relationship is Established [N4950 §31.7.5]:
\mathrm{store_{\mathrm{release}(x) \xrightarrow{\mathrm{sw} \mathrm{load_{\mathrm{acquire}(x)
This creates a happens-before edge, and all memory operations sequenced-before the release store are Visible to all operations sequenced-after the acquire load.
Correct Message Passing with Acquire/Release
Section titled “Correct Message Passing with Acquire/Release”#include <atomic>#include <iostream>#include <thread>
std::atomic<bool> flag{false};int data = 0;
void writer() { data = 42; // Non-atomic write, sequenced before release flag.store(true, std::memory_order_release); // Release prevents reordering: data=42 is guaranteed visible before flag=true}
void reader() { while (!flag.load(std::memory_order_acquire)) {} // Acquire prevents reordering: data read happens after flag read // Synchronizes-with the release store, so data=42 is visible std::cout << "data = " << data << "\n";}
int main() { std::jthread t1(reader); writer(); return 0;}// Output: data = 42 (guaranteed)The Synchronizes-With Relationship
Section titled “The Synchronizes-With Relationship”The synchronizes-with relationship is the fundamental building block of the C++ memory model. It Establishes a happens-before edge between threads, which in turn guarantees that all memory Operations sequenced-before the release store are visible to operations sequenced-after the acquire Load.
Formally [N4950 §6.9.2.2]:
A \xrightarrow{\mathrm{sb} B \implies A \xrightarrow{\mathrm{hb} B A \xrightarrow{\mathrm{sw} B \implies A \xrightarrow{\mathrm{hb} B A \xrightarrow{\mathrm{hb} B \implies \mathrm{all side effects of A \mathrm{ visible to BSequentially Consistent Ordering
Section titled “Sequentially Consistent Ordering”memory_order_seq_cst [N4950 §31.7.5] provides the strongest guarantee: there exists a total order \mathrm{S over all seq_cst operations such that:
- Every
seq_cstoperation in \mathrm{S is consistent with the happens-before order. - Every
seq_cstload reads either the last precedingseq_cststore in \mathrm{S or a value written by a non-seq_cststore.
\forall\, a, b \in \mathrm{seq\_cst ops: a \lt_{\mathrm{total} b \mathrm{ or b \lt_{\mathrm{total} a
On x86, seq_cst stores require a MFENCE (or LOCK XCHG), and seq_cst loads require LFENCE On some implementations. On ARM, seq_cst operations use dmb ish barriers.
:::note Info Specified. This ensures maximum safety but may not be necessary in all cases. For Performance-critical code, consider using weaker orderings where appropriate. :::
The Store Buffering Problem (Why seq_cst Is Needed)
Section titled “The Store Buffering Problem (Why seq_cst Is Needed)”Even with acquire/release, the following scenario can produce unexpected results:
#include <atomic>#include <iostream>#include <thread>
std::atomic<int> x{0};std::atomic<int> y{0};int r1 = 0;int r2 = 0;
// Thread 1: stores x=1 (release), then loads y (acquire)void thread1() { x.store(1, std::memory_order_release); r1 = y.load(std::memory_order_acquire);}
// Thread 2: stores y=1 (release), then loads x (acquire)void thread2() { y.store(1, std::memory_order_release); r2 = x.load(std::memory_order_acquire);}
int main() { std::jthread t1(thread1); std::jthread t2(thread2); // With acquire/release, it is possible that r1==0 && r2==0 // This is because acquire/release only creates ordering between // the store and load on the SAME atomic variable, not across variables. std::cout << "r1=" << r1 << ", r2=" << r2 << "\n"; return 0;}// Possible output: r1=0, r2=0 (surprising but valid with acquire/release)With memory_order_seq_cstThe outcome r1==0 && r2==0 is impossible because the total order on seq_cst operations prevents it. The seq_cst total order ensures that either Thread 1’s store to x happens before Thread 2’s store to yOr vice versa, and in either case, the other thread’s Load sees the store.
Producer-Consumer with Acquire/Release
Section titled “Producer-Consumer with Acquire/Release”#include <iostream>#include <atomic>#include <thread>#include <vector>#include <chrono>
struct message { int payload;};
constexpr int BUFFER_SIZE = 16;
class spsc_queue { alignas(64) std::atomic<int> write_idx_{0}; alignas(64) std::atomic<int> read_idx_{0}; message buffer_[BUFFER_SIZE];
public: bool try_push(message msg) { int write = write_idx_.load(std::memory_order_relaxed); int next = (write + 1) % BUFFER_SIZE; int read = read_idx_.load(std::memory_order_acquire);
if (next == read) { return false; }
buffer_[write] = std::move(msg); write_idx_.store(next, std::memory_order_release); return true; }
bool try_pop(message& msg) { int read = read_idx_.load(std::memory_order_relaxed); int write = write_idx_.load(std::memory_order_acquire);
if (read == write) { return false; }
msg = std::move(buffer_[read]); read_idx_.store((read + 1) % BUFFER_SIZE, std::memory_order_release); return true; }};
int main() { spsc_queue queue;
std::jthread producer([&queue] { for (int i = 0; i < 100; ++i) { while (!queue.try_push({i})) { std::this_thread::yield(); } } });
std::jthread consumer([&queue](std::stop_token stoken) { int received = 0; while (received < 100 && !stoken.stop_requested()) { message msg; if (queue.try_pop(msg)) { if (msg.payload != received) { std::cout << "ERROR: expected " << received << " got " << msg.payload << "\n"; } ++received; } } std::cout << "Received " << received << " messages\n"; });
return 0;}Why alignas(64) Matters
Section titled “Why alignas(64) Matters”The alignas(64) on the indices places them on separate cache lines (64 bytes is the typical cache Line size on x86). Without this, both indices might share a cache line, causing false sharing: Every write to write_idx_ invalidates the cache line containing read_idx_ (and vice versa), Forcing the other core to re-fetch from main memory. False sharing can reduce throughput by 5-10x on Multi-core systems.
Memory Ordering Comparison Table
Section titled “Memory Ordering Comparison Table”| Operation | x86 | ARMv8 | POWER8 | Compiler Barrier Only |
|---|---|---|---|---|
relaxed load | mov | ldar | ld | No |
relaxed store | mov | stlr | st | No |
acquire load | mov | ldar | ld + sync | No |
release store | mov | stlr | lwsync + st | No |
seq_cst load | mov | ldar | ld + sync | No |
seq_cst store | MFENCE (or XCHG) | stlr + dmb ish | lwsync + st + sync | No |
acq_rel RMW | LOCK XADD | ldaxr+stlxr | sync + ldar + st + sync | No |
:::tip Tip Provides those ordering guarantees. The only extra cost is for seq_cst stores (which require MFENCE). On ARM and POWER, acquire and release require explicit barrier instructions, so the Performance difference between relaxed and acquire/release is significant on those architectures. :::
Hardware Memory Models
Section titled “Hardware Memory Models”Understanding why the ordering costs differ requires understanding the underlying hardware memory Models:
x86 (Total Store Order — TSO):
- Loads are never reordered with other loads.
- Stores are never reordered with other stores.
- Loads are never reordered with older stores to the same address.
- Stores may be reordered after loads (this is the only relaxation vs. Sequential consistency).
- Result: acquire loads and release stores are free; only
seq_cststores needMFENCE.
ARMv8 (Weakly Ordered):
- No reordering guarantees by default.
- All ordering must be explicitly requested via
LDAR``STLROrDMBinstructions. LDARprovides load-acquire semantics.STLRprovides store-release semantics.DMB ISHprovides a full barrier.
POWER8 (Relaxed):
- Even weaker than ARMv8.
- Requires explicit
SYNC(full barrier) andLWSYNC(lightweight sync) instructions. LWSYNCprovides release semantics but is not a full acquire.- A full acquire requires
SYNC.
Fence Operations
Section titled “Fence Operations”std::atomic_thread_fence [N4950 §31.7.8] inserts a memory fence without an associated atomic Operation:
std::atomic_thread_fence(std::memory_order_release): Prevents preceding memory operations from being reordered past the fence.std::atomic_thread_fence(std::memory_order_acquire): Prevents subsequent memory operations from being reordered before the fence.std::atomic_thread_fence(std::memory_order_seq_cst): Both acquire and release, plus participates in the total seq_cst order.
std::atomic_signal_fence [N4950 §31.7.8] is a lighter-weight fence that prevents reordering Between a signal handler and the code interrupted by the signal. It generates only a compiler Barrier (no hardware instructions).
#include <atomic>#include <iostream>
int main() { int data = 0; std::atomic<bool> flag{false};
// Producer side (in another thread): // data = 42; // std::atomic_thread_fence(std::memory_order_release); // flag.store(true, std::memory_order_relaxed);
// Consumer side: if (flag.load(std::memory_order_relaxed)) { std::atomic_thread_fence(std::memory_order_acquire); std::cout << "data = " << data << "\n"; } return 0;}Fence Synchronizes-With Rules
Section titled “Fence Synchronizes-With Rules”A release fence F synchronizes-with an acquire fence G if:
- There exists an atomic store
Asequenced-beforeF. - There exists an atomic load
Bsequenced-afterG. Astores to the same atomic variableXthatBloads from.Breads the value written byAor a value written afterAin the modification order ofX.
This is more complex than direct acquire/release on atomic operations and is why fences are Discouraged in favor of direct memory ordering on atomic loads and stores.
:::note Info Stores directly, as they are more readable and equally efficient. Fences are primarily useful when Interfacing with hardware or when the atomic operation itself is performed by non-standard means. :::
memory_order_consume: The Problematic Ordering
Section titled “memory_order_consume: The Problematic Ordering”memory_order_consume was intended to optimize cases where data dependency ordering is sufficient (carries-a-dependency-to). However, it is effectively deprecated because no major compiler Implements it correctly — they all promote it to memory_order_acquire to avoid the complexity of Tracking data dependencies through the compiler’s intermediate representation.
#include <atomic>#include <iostream>
struct Node { int data; std::atomic<Node*> next{nullptr};};
std::atomic<Node*> head{nullptr};
void producer() { static Node n3{30, nullptr}; static Node n2{20, &n3}; static Node n1{10, &n2}; head.store(&n1, std::memory_order_release);}
void consumer() { Node* p = head.load(std::memory_order_consume); // In theory, consume only guarantees that p->next and p->data // are visible (data dependency). Other memory is not guaranteed. // In practice, compilers treat this as acquire. if (p) { std::cout << p->data << "\n"; if (p->next) { std::cout << p->next->data << "\n"; } }}
int main() { producer(); consumer(); return 0;}The C++26 proposal P0371R3 formalizes the deprecation by recommending that compilers treat memory_order_consume as memory_order_acquire.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Mixing Memory Orders on the Same Variable
Section titled “Pitfall 1: Mixing Memory Orders on the Same Variable”Using different memory orders on the same atomic variable for load and store is valid but requires Careful reasoning. A release store paired with a relaxed load does NOT establish synchronizes-with:
// WRONG: relaxed load does not synchronize with release store// producer: data = 42; flag.store(true, memory_order_release);// consumer: while (!flag.load(memory_order_relaxed)); cout << data;// UB: no synchronizes-with relationship, data may be stalePitfall 2: Forgetting That Non-Atomic Accesses Are Still UB
Section titled “Pitfall 2: Forgetting That Non-Atomic Accesses Are Still UB”Even with correct memory ordering on the flag, non-atomic accesses to shared data without a Happens-before relationship constitute data races and are undefined behavior. Acquire/release must Be used on both the flag AND the data must be sequenced-before the release / after the acquire.
Pitfall 3: seq_cst Does Not Prevent All Surprises
Section titled “Pitfall 3: seq_cst Does Not Prevent All Surprises”memory_order_seq_cst prevents the store-buffering problem but does not prevent other concurrency Issues like lost updates. A seq_cst fetch_add is still needed for atomic increments; a plain seq_cst load-modify-store sequence is not atomic:
// WRONG: load-modify-store is not atomic even with seq_cst// int old = counter.load(memory_order_seq_cst);// counter.store(old + 1, memory_order_seq_cst);// CORRECT: atomic RMW// counter.fetch_add(1, memory_order_seq_cst);Summary
Section titled “Summary”This topic covers the core concepts of memory orderings, including underlying theory, practical implementation, and key applications.
Key concepts include:
- core concepts and terminology
- algorithms and computational thinking
- practical implementation
- security and ethical considerations
- applications in the real world
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.