AlgoInsight

Free lesson · Concurrency Interview Mastery

The C++ Memory Model

Medium10 min readconcurrencymultithreadingcpp

Module 1 taught the universal memory-model problem - invisible writes, instruction reordering - and the Java lesson showed how Java tames it with happens-before. C++ has a memory model too, formalized in C++11, and it shares the same machinery. But C++ adds a consequence so severe it changes how you think: in C++, a data race is undefined behavior. Not a wrong value. Not a stale read. Undefined behavior - the compiler is permitted to do literally anything, including crash, corrupt unrelated data, or appear to work until it catastrophically doesn't. This lesson is about that harsher reality and the rules that keep you out of it.

The systems I work on at Google are written in C++ precisely because it gives you control and performance, but that control comes with the responsibility to never let a data race happen - because the language won't protect you from one, it'll just say "you broke the contract, all bets are off."

Data race = undefined behavior

Let me state the core rule precisely, because the wording matters:

A data race occurs when two threads access the same memory location concurrently, at least one access is a write, and the accesses are not ordered by synchronization. In C++, a data race is undefined behavior (UB).

Compare this to other languages. In Java, an unsynchronized concurrent access gives you a wrong but bounded result - a stale value, a lost update - and the program keeps running in a defined (if incorrect) state. In C++, the standard makes no such promise. A data race means your program has no defined meaning at all. The compiler, having assumed no data races (because you promised), may have optimized your code in ways that now produce arbitrary behavior. It might work in testing and detonate in production. It might corrupt memory far from the racing variables.

Warning: This is the single most important thing to internalize about C++ concurrency, and a frequent interview discriminator. When asked "what happens with a data race in C++?", the wrong answer is "you get a stale or wrong value." The right answer is "it's undefined behavior - the program has no defined semantics, and anything can happen." That distinction - UB versus merely-incorrect - is exactly what separates someone who understands C++'s contract from someone who's transplanting Java intuitions. C++ doesn't give you a bad answer; it voids the warranty.

How you avoid UB: synchronization establishes ordering

The escape from data-race UB is the same fundamental idea as Java: establish a happens-before ordering between conflicting accesses, so they're no longer "concurrent and unordered." C++ gives you the tools to create that ordering:

std::mutex m;
int shared = 0;

void writer() {
    std::lock_guard<std::mutex> lock(m);
    shared = 42;            // protected - no race
}

void reader() {
    std::lock_guard<std::mutex> lock(m);
    std::cout << shared;    // sees writer's value, properly ordered
}

The mutex does two jobs, exactly as in Java: mutual exclusion (one thread in the critical section at a time) and establishing the happens-before ordering that makes the writer's update visible to the reader. Without the mutex, those two accesses to shared (one write, one read, concurrent, unordered) would be a data race and therefore UB.

flowchart LR
    subgraph Unsafe["Unsynchronized - DATA RACE"]
        W1[Thread A: write x] -.concurrent, unordered.-> R1[Thread B: read x]
        R1 --> UB["Undefined Behavior:<br/>anything can happen"]
    end
    subgraph Safe["Synchronized via mutex"]
        W2[A: lock, write x, unlock] -->|happens-before| R2[B: lock, read x, unlock]
        R2 --> OK["Defined: B sees A's write"]
    end
This diagram contrasts unsynchronized and synchronized access in C++. On top, labeled data race, thread A writes a variable x and thread B reads x, concurrently and without ordering. In C++ this is undefined behavior, meaning anything can happen. On the bottom, labeled synchronized via mutex, thread A locks the mutex, writes x, and unlocks, which happens-before thread B locking the mutex, reading x, and unlocking. This produces defined behavior: B reliably sees A's write. The key point is that synchronization converts an undefined-behavior data race into well-defined, ordered access.

Reordering and the "as-if" rule

The reason data races are UB connects to how aggressively C++ optimizes. The compiler and CPU may reorder operations under the "as-if" rule - they can transform your code however they like as long as a single thread observes the same results as if no reordering happened. This is the same single-thread-preserving guarantee from Module 1: optimizations are invisible to the thread doing them, but they can wreak havoc across threads.

Because the compiler assumes your program is data-race-free, it optimizes based on that assumption. If you then introduce a data race, the optimizations the compiler already made - caching a value in a register, reordering two writes, eliminating a "redundant" read - can produce behavior that makes no sense relative to your source code. The race doesn't just expose a reordering; it violates the premise the compiler built everything on. That's why the consequence is undefined rather than merely unexpected.

Important: The mental model: C++ promises to run your code "as if" sequentially for each thread in isolation. It promises nothing across threads unless you add synchronization. So every piece of shared mutable state needs a mutex or an atomic to create the cross-thread ordering the compiler otherwise won't preserve. "Just being careful" is never enough in C++ - without an actual synchronization mechanism, you have a data race, and a data race is UB regardless of how careful your logic looks.

Sequential consistency: the default for atomics

C++'s memory model has a subtle richness the next lesson explores fully, but here's the headline: by default, atomic operations use sequential consistency (memory_order_seq_cst), the strongest and most intuitive ordering. Under sequential consistency, all threads observe atomic operations in a single, consistent global order, which matches the intuition most programmers have. C++ then also lets you relax that ordering for performance - acquire, release, relaxed - which is power no other mainstream language in this course exposes. But the default is the safe, intuitive one, and you should stay on it until you have measured reason and deep understanding to do otherwise.

Tip: Default to mutexes for protecting shared state, and to sequentially-consistent atomics when you need lock-free single-variable updates. Only descend into relaxed memory orderings when profiling proves you need the performance and you can prove the weaker ordering is still correct - which is genuinely hard. The default tools give you defined, intuitive behavior; the relaxed orderings give you speed at the cost of reasoning difficulty that has humbled experts. We'll see exactly how in the next lesson.

What "undefined behavior" actually licenses

People nod along at "data race is undefined behavior" without grasping how radical the consequence is, so let me make it concrete. When the standard says a program with a data race has undefined behavior, it means the compiler is permitted to assume your program contains no data races and optimize on that assumption. Every optimization the compiler performs - caching a variable in a register so it doesn't re-read memory, reordering two independent stores, eliminating a read it believes is redundant, even deleting an entire branch it can prove is unreachable in a race-free program - is justified by the no-data-race premise. Introduce a race, and you've falsified the premise the compiler built its entire optimization strategy on. The result isn't "the variable has a slightly stale value." The result is that the machine code no longer corresponds to your source code in any predictable way.

This is why undefined behavior can produce effects that seem absurd: a racing program might corrupt memory in a completely different part of the system, might appear to work for months and then fail when a compiler upgrade changes which optimizations fire, might behave differently at different optimization levels, or might crash in a function that doesn't even touch the racing variable. None of that is a bug in the compiler - it's the compiler correctly doing what you gave it permission to do when you violated the no-race contract. Undefined behavior is not "unpredictable but bounded"; it's genuinely unbounded.

A concrete cautionary example

Consider a flag that one thread sets to stop another:

bool stop = false;            // plain bool, NOT atomic - this is the bug

void worker() {
    while (!stop) {           // the compiler may assume stop never changes here
        do_work();
    }
}
// another thread does: stop = true;

This looks obviously correct, and it is a data race, therefore undefined behavior. The compiler, seeing that worker never modifies stop, is free to assume stop is invariant inside the loop and hoist the check out entirely - transforming while (!stop) into if (!stop) while (true). Now setting stop = true from another thread has no effect at all, because the running thread never re-reads it. The loop spins forever. The fix is to make stop a std::atomic<bool>, which both makes the access data-race-free and forbids the compiler from caching it. This exact bug - a non-atomic stop flag that the compiler optimizes into an infinite loop - is one of the most common real-world C++ concurrency failures, and it perfectly illustrates how a data race becomes undefined behavior rather than merely a delayed read.

The practical discipline

The discipline that keeps you safe is simple to state and must become reflexive: every piece of data shared between threads must be accessed only under a mutex or be a std::atomic. There is no middle ground where a plain variable accessed by multiple threads is "probably fine because the operation is small." A single byte read and written by two threads without synchronization is a data race and therefore undefined behavior, full stop. The smallness or apparent atomicity of the operation at the source level is irrelevant - the standard's contract is about synchronization, not about operation size.

This is stricter than the intuition most programmers carry from higher-level languages, where an unsynchronized access merely risks a wrong value. In C++, you cannot reason about "what value will I get" from a race, because the question presupposes defined behavior that a race destroys. The only correct stance is prevention: identify every shared mutable access and put it behind a mutex or convert it to an atomic. Tools like ThreadSanitizer can detect data races at runtime and are worth running routinely on concurrent C++ precisely because the language gives you no safety net for the races you miss.

How mutexes and atomics fit the same model

It helps to see that mutexes and atomics aren't two unrelated tools - they're two ways of buying the same thing the memory model requires: a happens-before ordering between conflicting accesses, so they're no longer an unsynchronized data race. A mutex provides that ordering at the granularity of a critical section: everything one thread does while holding the lock happens-before everything the next thread does after acquiring the same lock, so any number of reads and writes inside the critical section are published as a unit. An atomic provides that ordering at the granularity of a single variable: the atomic operation itself is data-race-free, and (with the right memory order) it can establish ordering for the surrounding non-atomic writes too. Both are answers to the same question - "how do I make these conflicting accesses ordered rather than racing?" - at different granularities.

This framing tells you when to reach for which. Use a mutex when you need to protect a group of operations or a compound invariant - updating several related fields together, maintaining a data structure's consistency across multiple steps, or any critical section where the protected work is more than a single variable. Use an atomic when you need a single shared variable updated safely and you want to avoid the cost of a lock - a counter, a flag, a single pointer swapped atomically. A mutex is the general tool that handles arbitrary critical sections; an atomic is the specialized, lock-free tool for single-variable operations. Reaching for an atomic where you really need to protect a multi-step invariant is a classic mistake: an atomic makes each individual operation safe, but it can't make a sequence of operations atomic as a group - that still requires a mutex.

There's also a performance dimension worth knowing. Atomics are generally cheaper than mutexes for the single-variable case because they avoid the overhead of acquiring and releasing a lock and avoid blocking - a thread doing an atomic operation never waits for another thread. But that advantage only applies when your problem genuinely is a single-variable update. The moment you need to coordinate multiple variables or protect an invariant spanning several operations, a mutex is both correct and clearer, and trying to force the problem into atomics leads to the fragile lock-free code that the next lesson warns you to approach with extreme caution. Match the tool to the granularity of what you're protecting, and the memory model's requirements are satisfied either way.

Note: A useful way to remember the C++ stance: other languages ask "what value will I get from this race?" and answer "a wrong one." C++ rejects the question itself - a race means the program has no defined behavior, so asking what value you'll get is like asking what color a contradiction is. That reframing, from "incorrect result" to "no defined meaning," is the whole mental shift this lesson is trying to install, and it's why prevention rather than reasoning-about-outcomes is the only viable approach in C++.

The interview angle

Interview: The defining C++ memory-model question: "What happens with a data race in C++?" - Answer: it's undefined behavior, not merely a wrong value - the program has no defined semantics and the compiler may do anything, because it optimized assuming no data races. This is harsher than Java, where an unsynchronized access yields a bounded-but-incorrect result. Follow-ups: "How do you avoid it?" - Establish happens-before ordering via a mutex, an atomic, or thread join/start; every shared mutable access needs real synchronization. "What's the as-if rule?" - The compiler/CPU may reorder freely as long as single-threaded observable behavior is preserved, which is why cross-thread access without synchronization breaks. "What's the default memory order for atomics?" - Sequential consistency, the strongest and most intuitive. Leading with "data race equals UB" immediately marks you as someone who understands C++'s actual contract.

Quick reference

C++ data race  = concurrent access, >=1 write, no synchronization
               = UNDEFINED BEHAVIOR (not just wrong - anything can happen)

Avoid via synchronization that establishes happens-before:
  std::mutex      -> lock/unlock ordering (mutual exclusion + visibility)
  std::atomic     -> data-race-free ops, can order threads
  thread join     -> ordering edge

"as-if" rule: compiler/CPU may reorder if single-thread results preserved
default atomic order: memory_order_seq_cst (sequential consistency, intuitive)

Rule: every shared mutable access needs a mutex or atomic. "Careful" != safe.

Next, the tool that makes lock-free C++ possible and exposes the finest-grained memory control of any language here: std::atomic and its memory-order parameters.

Back to Concurrency Interview Mastery