Hello! Welcome to our next lesson on C++ concurrency.
In our last session, we mastered the mechanics of std::thread lifecycle management—creating, joining, and detaching threads. We established that a std::thread object is an RAII handle for an OS-level thread and underscored the critical rule: you must join() or detach() it before its destruction to avoid program termination. This set us up with the ability to run multiple threads, but also poised us to face the challenges that come with it.
Today, we confront the most fundamental problem in concurrent programming. Our goal is to analyze race conditions and data races at the memory level. We'll start by observing a "race condition" in action, then dive deep into the low-level reasons it occurs—right down to the assembly instructions and compiler optimizations. Finally, we will formalize this into the C++ standard's concept of a "data race" and understand why it is one of the most severe bugs a C++ program can have.
The Race Condition: An Unpredictable Outcome
A race condition is a situation where the behavior of a system depends on the unpredictable sequence or timing of events, such as the interleaving of thread executions. When threads share modifiable data, this often leads to corrupted results.
Let's see this happen. Imagine two threads are tasked with incrementing a shared counter one million times each. The expected final value is two million.
This video from CodeVault provides a perfect demonstration of a race condition. It will show you how a seemingly simple program produces an incorrect result and then begin to explain why.
Watch from the beginning to 03:54. Observe how the program with a shared counter behaves correctly with a low number of iterations but fails dramatically as the number of operations increases.
As you saw, the final count is not two million and varies with each run. Why does this happen? The root cause is that a high-level operation that seems like a single step is not, in fact, atomic.
Anatomy of an Increment: Read-Modify-Write
The C++ expression males++ is not a single, indivisible instruction for the CPU. To understand the race condition, we must break this operation down into the sequence of steps the CPU actually performs.
Let's continue with the same video to see the breakdown of the increment operation and a visualization of the interleaving.
Watch from 03:54 to 08:59. This segment explains the three-step 'read-modify-write' cycle and uses a table to show exactly how two threads can interleave these steps, leading to lost updates.
The three fundamental steps are:
- Read: Load the current value of the variable from main memory into a CPU register.
- Modify: Increment the value within the CPU register.
- Write: Store the new value from the register back into main memory.
The race condition occurs because the operating system's scheduler can pause a thread at any point—including between these three steps.

Your strong C background gives you a good mental model for how code maps to machine instructions. Let's make that explicit by looking at the assembly generated for this simple C++ increment.
The final piece from this video connects the high-level C++ code directly to the low-level assembly, confirming the read-modify-write cycle.
Watch from 08:59 to 11:02. Focus on how the single males++ line translates into multiple assembly instructions: mov (read), add (modify), and mov (write). This is the 'under the hood' evidence of non-atomicity.
The Plot Thickens: Compiler and CPU Reordering
Simple instruction interleaving is only part of the story. Modern compilers and CPUs perform aggressive optimizations that can reorder instructions to improve performance. These optimizations are governed by the as-if rule: the compiler can make any transformation, as long as the observable behavior of the program in a single-threaded context is unchanged.
This rule is a cornerstone of performance, but it breaks down in a multithreaded world when data is shared without synchronization. A compiler or CPU might reorder memory reads and writes in a way that is perfectly valid for a single thread but disastrously wrong when another thread is observing that same memory.
C++ Memory Model: from C++11 to C++23 - Alex Dathskovsky - CppCon 2023
This CppCon presentation by Alex Dathskovsky dives into the C++ memory model. We'll watch a segment that explains the types of optimizations that can introduce non-obvious reordering, which is a primary cause of concurrency bugs.
Watch from 02:06 to 20:48. This is a dense but crucial segment. Focus on these key ideas: Compiler Optimizations (02:06 - 08:00): How compilers reorder, unroll, and eliminate code. CPU Optimizations (08:00 - 14:50): The concepts of pipelining and out-of-order execution. The 'as-if' Rule (14:50 - 17:23): How this rule permits reordering. Multithreading Implications (17:23 - 20:48): How reordering breaks a naive flag-based synchronization attempt and introduces the concept of 'sequential consistency for data-race-free programs'.
A Note on volatile: The C Programmer's Trap
Coming from C, you might think volatile could be used to solve this. This is a common and dangerous misconception. In C++, volatile has a very limited meaning: it tells the compiler that the value of a variable may change by means unknown to the compiler itself (e.g., a memory-mapped hardware register). It prevents the compiler from optimizing away reads or writes to that variable within a single thread.
volatile does not:
- Guarantee atomicity. An increment on a
volatile intis still a non-atomic read-modify-write sequence. - Prevent CPU instruction reordering.
- Create memory barriers needed for synchronization between threads.
Using volatile for inter-thread synchronization is incorrect and leads to undefined behavior.
C++ Memory Model: from C++11 to C++23 - Alex Dathskovsky - CppCon 2023
Let's hear this directly from the expert. This part of the same CppCon talk explicitly debunks the use of volatile for synchronization.
Watch from 22:09 to 24:50. The speaker's message is clear: 'volatiles are not synchronization tools'. This is a critical takeaway for anyone moving from C-style concurrency to modern C++.
Data Races and Undefined Behavior
The C++ standard provides a precise definition for this problem. A data race occurs when:
- Two or more threads concurrently access the same memory location.
- At least one of the accesses is a write.
- The threads do not use any exclusive synchronization mechanism (like a mutex).
The key takeaway is this: a program with a data race has Undefined Behavior (UB).
This is not a theoretical problem. UB means the compiler is free to generate code that does anything—it might appear to work, crash, produce garbage results, or corrupt memory. This "catch-fire" semantic is chosen because it gives compilers maximum freedom to optimize data-race-free code. The standard assumes you will provide the necessary synchronization.
The Myth of "Benign" Data Races
You may encounter programmers who believe some data races are "benign" or harmless. For example, what if two threads write the exact same value to a variable? Or what if one thread reads while another writes, and the reader can accept either the old or the new value?
In C++, this reasoning is dangerously flawed. The presence of a data race gives the compiler license to perform transformations that are nonsensical from the programmer's point of view but are technically legal according to the "as-if" rule.
How to miscompile programs with “benign” data races
The classic paper 'How to miscompile programs with “benign” data races' by Hans Boehm explains this with several eye-opening examples. We'll focus on two of the most illustrative cases.
Read the following two sections from this paper: Section 2.1 'Double checks for lazy initialization': This is a classic broken pattern that demonstrates how memory operation reordering can cause a thread to read uninitialized data. Section 2.4 'Redundant writes': This is a more subtle and powerful example. It shows how the compiler, justified by the presence of a 'redundant write' data race, can perform a transformation (register promotion) that causes updates from another thread to be lost entirely. Focus on understanding how the compiler's assumption of data-race-free code justifies transformations that break the program's logic.
The paper demonstrates that even seemingly harmless races can enable compiler optimizations that lead to catastrophic failure. For example:
- Register Spilling: A compiler might read a shared variable into a register. If it needs that register for something else (spilling), it might not save the value back to memory, assuming it can just re-read it later. If another thread changed the value in the meantime, the program's logic becomes inconsistent.
- Disjoint Bits: Even updating different bits within the same
intorstructis a data race. The compiler treats adjacent bit-fields as a single memory location. An update requires a read-modify-write of the entire location, which can clobber a concurrent update to other bits.
The conclusion is absolute: there is no such thing as a benign data race in portable C++.
Test your understanding!
You have a struct with two flags. Thread A only ever sets flag1, and Thread B only ever sets flag2.
struct Flags {
bool flag1 = false;
bool flag2 = false;
};
Flags g_flags;
// Thread A
g_flags.flag1 = true;
// Thread B
g_flags.flag2 = true;
Based on what you've learned, does this code contain a data race? Why or why not? What could go wrong?
Show answer
Yes, this code contains a data race.
Why: The C++ memory model specifies that adjacent non-static data members (like flag1 and flag2) that are not separated by access specifiers can be packed by the compiler into a single memory location. In this case, the two bools might be stored in the same byte.
What could go wrong: When Thread A executes g_flags.flag1 = true;, the CPU might perform a read-modify-write cycle on the entire byte containing both flags.
- Read: Thread A reads the byte containing
(flag1=false, flag2=false). - Modify: It modifies its local copy to
(flag1=true, flag2=false). - Context Switch: The OS switches to Thread B.
- Read-Modify-Write (Thread B): Thread B reads the original byte
(false, false), modifies it to(false, true), and writes it back.g_flagsnow holds(false, true). - Context Switch: The OS switches back to Thread A.
- Write: Thread A writes its local copy,
(true, false), back to memory.
The final state is g_flags = (flag1=true, flag2=false). The update from Thread B has been lost. This illustrates why even operations on logically distinct data can race if they share a memory location.
Conclusion
Today, we took a critical look at the core danger of concurrent programming. We've gone from observing an unpredictable result to understanding its root causes at the hardware and language-specification levels.
Key Takeaways:
- A race condition is an undesirable dependency on the timing of threads, often caused by non-atomic operations on shared data.
- Simple C++ operations like
x++are not atomic. They decompose into a read-modify-write sequence of machine instructions, which can be interleaved between threads. - Compilers and CPUs aggressively reorder instructions based on the single-threaded "as-if" rule, which can break multithreaded code that lacks explicit synchronization.
- The C++ standard defines a data race as unsynchronized, concurrent access to memory where at least one access is a write. A data race results in Undefined Behavior.
- There are no "benign" data races in C++. Any such race gives the compiler license to perform transformations that may break your program in non-obvious ways.
volatileis not a synchronization tool for multithreading in C++.
What's Next?
We have thoroughly diagnosed the disease. In our next lesson, we will learn about the cure. We will introduce the most fundamental synchronization primitive in C++: the std::mutex. We will see how to use it to define critical sections—blocks of code that only one thread can execute at a time—thereby protecting our shared data and eliminating data races.