Hello! In our last lesson, we mastered std::condition_variable to build efficient, event-driven concurrent systems. You learned how primitives like mutexes and condition variables allow us to coordinate threads and prevent them from interfering with each other in chaotic ways.
But what are the fundamental rules governing this coordination? Why can't we just write to a shared bool flag from one thread and read it from another without a mutex? The answer lies deep in how modern compilers and CPUs are designed to optimize for performance.
Today, we will dive into one of the most fundamental and critical topics in C++ concurrency: the C++ Memory Model. We'll move beyond just using synchronization tools and start understanding the formal rules that make them work. You will learn what a data race truly is and how the crucial concept of the happens-before relationship provides the guarantees we need to write correct, portable, and high-performance concurrent code. This is the "first principles" foundation for everything we do in multithreading.
Part 1: The Problem of Reordering
In a single-threaded world, the compiler and CPU are given a license to be clever. As long as the observable behavior of your program remains the same, they are free to reorder instructions to make things faster. This is called the "as-if" rule.
For example, given this code:
int a = 1;
int b = 2;
The compiler might decide it's more efficient to store 2 into b first, and then 1 into a. In a single thread, you'd never notice. But what happens when another thread is watching?
// Shared global variables
int data = 0;
bool ready = false;
// Thread 1
void producer() {
data = 42; // A
ready = true; // B
}
// Thread 2
void consumer() {
while (!ready) {
// spin...
}
assert(data == 42); // C
}
You would intuitively expect this assertion to never fail. The producer sets the data, then sets the ready flag. The consumer waits for the ready flag, so it should definitely see the correct data.
However, due to reordering, the system is allowed to execute B before A. The CPU might write true to ready, making it visible to Thread 2, before the value 42 is written to data. Thread 2 would then break its loop, read an uninitialized or stale value from data (e.g., 0), and the assertion would fail.
This reordering isn't just a theoretical compiler trick; it also happens at the hardware level due to features like CPU caches and store buffers, which are designed to hide memory latency.
HFT 03 - How you could invent the C++ Memory model
The blog post 'How you could invent the C++ Memory model' provides a fantastic and intuitive explanation of these 'tricksters'—the compiler and the CPU—and why their optimizations cause problems in a multithreaded context.
Read the sections 'Part 1: Tricksters' and 'The conflict'. This will set the stage by clearly explaining how both compiler and CPU reordering can break naive concurrent code.
This brings us to a critical realization: the intuitive, sequential ordering of your source code is an illusion in a multithreaded context. We need a formal contract to reason about what is and isn't guaranteed. That contract is the C++ Memory Model.
Part 2: The Contract: Data Races and the SC-DRF Guarantee
The C++ standard provides a way out of this chaos with a powerful guarantee known as "Sequential Consistency for Data-Race-Free Programs" (SC-DRF). It's a deal between you and the system:
- Your Part: You must write a program that is free of data races.
- The System's Part: If you uphold your end, the system guarantees that your program will execute as if it were a simple interleaving of operations from all threads, with no reordering surprises.
So, what is a data race? The formal definition is:
A program has a data race if it contains two conflicting accesses to the same memory location, at least one of which is non-atomic, and they are not ordered by a happens-before relationship.
- Conflicting Accesses: Two operations conflict if they access the same memory location, and at least one of them is a write.
- Happens-Before: This is the key concept we will define next. It's the mechanism that imposes an order on operations, even across different threads.
If you have a data race, your program has Undefined Behavior (UB). It might crash, it might produce the wrong result, or it might appear to work correctly for years until a specific hardware or compiler version exposes the bug. This is why avoiding data races is the single most important rule of C++ concurrency.
Part 3: The Core Mechanism: The "Happens-Before" Relationship
"Happens-before" is a causality relationship. If operation A happens-before operation B, then the effects of A are guaranteed to be visible to the thread executing B before B begins. This relationship is the backbone of the memory model and is built from two simpler relations:
- Sequenced-before: This is the familiar intra-thread ordering. In a single thread, if statement
Xcomes before statementYin your code, thenXis sequenced-beforeY. - Synchronizes-with: This is the inter-thread magic. It establishes ordering between operations on different threads. This is created by synchronization primitives like
std::atomicorstd::mutex.
The happens-before relationship is the transitive closure of these two. This means if A is sequenced-before B, and B synchronizes-with C, then A happens-before C.
Let's make this concrete with std::atomic and its memory orders.
- A
storeoperation withstd::memory_order_releaseon an atomic variable synchronizes-with aloadoperation withstd::memory_order_acquireon the same variable that reads the value written.
Think of it as passing a baton in a relay race. The release-store hands off memory visibility to the acquire-load.

Let's formalize this.
HFT 03 - How you could invent the C++ Memory model
The 'HFT 03' article again provides an excellent, structured build-up of the memory model, defining the key guarantees including 'Program order' (sequenced-before) and 'Happens-before'.
Now read 'Part 3: Building a SC-DRF Memory Model'. Focus on the definitions of 'Program order (sequenced-before)', 'Modification order', and especially 'Happens-before'. The analogy to distributed systems is very insightful.
Test your understanding!
You have two threads. Thread 1 writes to a non-atomic variable x. Thread 2 reads from x. There are no mutexes or atomic variables involved. Is there a happens-before relationship between the write in Thread 1 and the read in Thread 2? Why or why not?
Show answer
No, there is no happens-before relationship.
- The operations are in different threads, so there is no sequenced-before relationship between them.
- There are no synchronization operations (like mutexes or atomics), so there can be no synchronizes-with relationship.
Since there's no happens-before relationship between the conflicting accesses (a write and a read), this program has a data race and thus Undefined Behavior.
Part 4: A Practical Example and The Role of Mutexes
Let's fix our producer-consumer example using std::atomic to establish a happens-before relationship.
#include <atomic>
#include <thread>
#include <cassert>
int data = 0;
std::atomic<bool> ready = false;
// Thread 1
void producer() {
data = 42; // A
ready.store(true, std::memory_order_release); // B
}
// Thread 2
void consumer() {
while (!ready.load(std::memory_order_acquire)); // C
assert(data == 42); // D
}
Let's trace the happens-before chain:
- The write to
data(A) is sequenced-before the atomic store toready(B) within Thread 1. - When the
loadat (C) reads thetruewritten by (B), thestoreat (B) synchronizes-with theloadat (C). - The
loadat (C) is sequenced-before theassertat (D) within Thread 2.
By transitivity (A -> B -> C -> D), the write to data (A) happens-before the read of data inside the assert (D). The conflicting accesses are ordered, there is no data race, and the assertion is guaranteed to succeed.
What about mutexes?
You don't always see std::atomic variables. The good news is that std::mutex::lock() and unlock() also establish this relationship for you!
- An
unlock()on a mutex synchronizes-with a subsequentlock()on the same mutex.
Effectively, a call to lock() acts as an acquire operation, and a call to unlock() acts as a release operation. All memory writes you performed before calling unlock() are guaranteed to be visible to another thread after it successfully calls lock(). This is why the mutexes and condition variables we've studied work correctly and prevent data races on the data they protect.
Part 5: Under the Hood - Memory Orders and Assembly
Your interest in low-level details is key here. The memory orders (release, acquire, etc.) are not just abstract rules; they translate into specific machine code that instructs the CPU on how to handle memory ordering. This is highly architecture-dependent.
Multithreading in C++: Memory Ordering - Ramtin's Blog
The blog post 'Multithreading in C++: Memory Ordering' provides a concise look at how different memory orders are implemented at the assembly level for x86 and ARM, which directly addresses your 'under the hood' interest.
Read the section 'Behind the Scenes' and pay close attention to the assembly output for x86 vs. ARM. Note how ARM requires an explicit 'dmb' (Data Memory Barrier) instruction for release semantics, while the strong x86 model often doesn't.
-
x86/x64 (Strong Memory Model): These CPUs provide strong ordering guarantees by default. Writes are generally not reordered with other writes. A simple
movinstruction often has "release" semantics automatically. To enforce the even stricterseq_cstorder, a more expensive instruction likexchg(which acts as a full memory fence) might be used. -
ARM (Weak Memory Model): These CPUs can and will reorder memory operations much more aggressively to save power and improve performance. To enforce acquire/release semantics, the compiler must emit explicit memory barrier instructions like
dmb(Data Memory Barrier). An acquire barrier prevents subsequent memory operations from being moved before it, and a release barrier prevents prior memory operations from being moved after it.

Understanding this shows why portable C++ concurrency relies on the memory model. By using std::atomic and mutexes, you are writing abstract, portable code that the compiler will translate into the correct, and often different, machine instructions needed for whatever architecture you're targeting.
Conclusion
You've now explored the fundamental contract that makes C++ concurrency possible. Without these rules, writing reliable multithreaded code would be nearly impossible in the face of aggressive compiler and hardware optimizations.
Key Takeaways:
- Compilers and CPUs reorder instructions to optimize performance, which is safe for single-threaded code but can break multithreaded code.
- The C++ Memory Model is a contract: if you write a data-race-free program, the system guarantees it will behave in a sequentially consistent way.
- A data race occurs when two conflicting accesses (one is a write) to the same non-atomic memory location are not ordered by a happens-before relationship.
- The happens-before relationship establishes causality. It's built from intra-thread sequenced-before and inter-thread synchronizes-with.
- Synchronization primitives like
std::mutex::unlock/lockandstd::atomic::store/load(with release/acquire semantics) create the crucial synchronizes-with relationship, preventing data races. - These memory ordering rules translate to specific, and sometimes costly, machine instructions (
fences,barriers) that differ across CPU architectures like x86 and ARM.
What's Next?
We've established the rules for correct synchronization. However, even with these rules, using synchronization primitives like mutexes can introduce another class of concurrency bugs. In our next lesson, we will analyze and learn to debug the most common of these: deadlock, where threads get stuck waiting for each other in a circular chain, bringing your program to a grinding halt.