Skip to content

Memory Orderings | C++ - Wyatt's Notes

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.

The std::memory_order enum [N4950 §31.7.5] defines six values:

ValueConstantOrdering Guarantee
memory_order_relaxed\emptysetNo ordering
memory_order_consumedependency-orderedData dependency on loaded value
memory_order_acquireacquireNo reads/writes after can be reordered before
memory_order_releasereleaseNo reads/writes before can be reordered after
memory_order_acq_relacquire + releaseBoth acquire and release
memory_order_seq_cstsequential consistencyTotal order across all seq_cst operations

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\_cst

Using a stronger ordering than necessary is always safe but may incur performance penalties. Using a Weaker ordering than required results in undefined behavior.

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

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 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 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 B

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:

  1. Every seq_cst operation in \mathrm{S is consistent with the happens-before order.
  2. Every seq_cst load reads either the last preceding seq_cst store in \mathrm{S or a value written by a non-seq_cst store.

\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.

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.

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

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.

Operationx86ARMv8POWER8Compiler Barrier Only
relaxed loadmovldarldNo
relaxed storemovstlrstNo
acquire loadmovldarld + syncNo
release storemovstlrlwsync + stNo
seq_cst loadmovldarld + syncNo
seq_cst storeMFENCE (or XCHG)stlr + dmb ishlwsync + st + syncNo
acq_rel RMWLOCK XADDldaxr+stlxrsync + ldar + st + syncNo

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_cst stores need MFENCE.

ARMv8 (Weakly Ordered):

  • No reordering guarantees by default.
  • All ordering must be explicitly requested via LDAR``STLROr DMB instructions.
  • LDAR provides load-acquire semantics.
  • STLR provides store-release semantics.
  • DMB ISH provides a full barrier.

POWER8 (Relaxed):

  • Even weaker than ARMv8.
  • Requires explicit SYNC (full barrier) and LWSYNC (lightweight sync) instructions.
  • LWSYNC provides release semantics but is not a full acquire.
  • A full acquire requires SYNC.

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

A release fence F synchronizes-with an acquire fence G if:

  1. There exists an atomic store A sequenced-before F.
  2. There exists an atomic load B sequenced-after G.
  3. A stores to the same atomic variable X that B loads from.
  4. B reads the value written by A or a value written after A in the modification order of X.

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.

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.

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 stale

Pitfall 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);
flowchart TD
    A[4_Memory_Orderings] --> B[Key Concepts]
    A --> C[Core Principles]
    A --> D[Practical Applications]
    B --> E[Fundamental definitions]
    C --> F[Design patterns]
    D --> G[Real-world usage]

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 demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

Memory orderings are like traffic rules for data. Relaxed ordering is like a country road with no traffic lights - cars can pass each other freely, but you might see them out of order. Acquire/release is like a one-way bridge - once you cross the bridge (acquire), you are guaranteed to see everything that was on the other side before the bridge opened (release). Sequential consistency is like a strict traffic light system - every car crosses the intersection in the same order. The key insight is that stronger orderings provide more guarantees but cost performance. Use the weakest ordering that satisfies your correctness requirements. The producer-consumer pattern is the classic example: the producer uses release (to publish data), and the consumer uses acquire (to see the data).