Welcome back! In our last lesson, you took your first steps into concurrent programming by learning how to create and manage threads with std::thread. We saw how a background worker thread could perform tasks without blocking the main application thread, a pattern that is central to the design of our telemetry engine.
Now that we have multiple threads running, we must confront the single most important challenge in concurrent programming: safely managing shared data. When multiple threads read and write to the same memory locations, the potential for chaos is enormous. The order of operations is no longer guaranteed, and this non-determinism can lead to subtle, catastrophic bugs.
Today, our goal is to identify data races and race conditions in concurrent code. Understanding how to spot these issues is the critical first step before we can learn how to prevent them.
The Problem: When Concurrent Operations Go Wrong
Let's start with a very simple program. We'll have a single shared integer and launch two threads. Each thread will run a loop, incrementing the integer one million times. What do you expect the final value to be?
Logically, it should be 2,000,000. But is that what happens in practice?
The following video demonstrates this exact scenario and reveals the unpredictable nature of concurrent execution.
The video 'What are Race Conditions?' from the CodeVault channel provides a perfect demonstration of the problem we're about to dissect.
Watch from the beginning to 03:54. Notice how the program behaves correctly with a small number of iterations but produces an incorrect and inconsistent result when the workload increases.
As you saw, the final count is not 2,000,000. It's a different, seemingly random number every time we run the program. This is our first encounter with a concurrency bug. But why does this happen?
Under the Hood: The Anatomy of an Increment
The root of the problem is that an operation as simple as counter++ is not "atomic". An atomic operation is one that is indivisible; from the perspective of other threads, it happens all at once. In reality, a C++ increment compiles down to multiple machine instructions that the CPU executes.
The process typically looks like this:
- 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.
Because a thread can be paused by the operating system at any point between these instructions, two threads can interleave their operations in a destructive way.

The next segment of the video breaks this down perfectly, showing both a table of interleaved operations and the actual assembly code generated by the compiler. Given your interest in low-level systems, seeing the assembly provides concrete proof that a single line of C++ is not a single CPU operation.
Let's continue with the CodeVault video to see the low-level explanation.
Watch from 03:54 to 11:02. Pay close attention to the table illustrating how thread interleaving leads to lost updates. The walkthrough of the assembly code (move, add, move) is particularly important as it demystifies why these bugs occur.
Precise Definitions: Race Condition vs. Data Race
The bug you've just witnessed is often generically called a "race condition," but in modern C++, it's crucial to be more precise. We need to distinguish between a race condition and a data race.
The article 'Race Condition vs. Data Race' by John Regehr is a classic and highly-respected explanation of this topic. It provides the formal definitions we need.
Read the first two sections, starting from the top down to the end of the bulleted list defining a data race. Focus on the precise definitions of each term.
Let's crystallize those definitions:
-
A Race Condition is a flaw in a system or program where the correctness depends on the sequence or timing of other uncontrollable events. It's a high-level, semantic flaw. In our counter example, the final sum being wrong is a race condition because the result depends on how the OS schedules the threads.
-
A Data Race is a very specific, low-level situation defined by four conditions:
- Two or more threads access the same memory location concurrently.
- At least one of the accesses is a write.
- The threads are not using any exclusive synchronization mechanism (like a lock) to protect the access.
- The accesses are not atomic operations.
In C++, a data race causes Undefined Behavior (UB). This is extremely serious. It doesn't just mean you get the wrong answer; it means the C++ standard makes no guarantees about your program's behavior at all. The program could crash, silently corrupt data, or appear to work correctly until a compiler update or change in hardware exposes the bug.
The key relationship is: Data races are a common cause of race conditions, but they are not the same thing.
Exploring the Nuances
It's possible to have one without the other. To understand this, let's look at a more complex example: transferring money between bank accounts. The following article uses this analogy to illustrate the four possible scenarios.
Race Conditions versus Data Races – MC++ BLOG
This article, 'Race Conditions versus Data Races' from Modernes C++ blog, provides C++ code examples that make the bank account analogy concrete.
Read the article from the beginning to the section 'Solving the data race is relatively easy.' Observe how the single-threaded version is correct, but the multi-threaded version has both a data race (on balance) and a race condition (the check-then-withdraw logic is not atomic).
The article you just read showed a case (transferMoney) with both a data race and a race condition. Let's refer back to the examples in John Regehr's blog post to explore the other combinations, which are critical for a deep understanding.
-
Race Condition, NO Data Race (
transfer2example): Imagine each individual read and write to the account balances is protected (e.g., usingstd::atomic, which we'll cover later). This eliminates the data race. However, the overall transaction logic is flawed:- Thread A reads
account_from.balance. It's sufficient. - Context Switch. Thread B runs a full transfer, draining
account_from. - Context Switch. Thread A resumes, thinking the balance is still sufficient, and proceeds with its withdrawal, resulting in an overdraft.
The program's invariant (conservation of money) is broken. This is a race condition without a data race.
- Thread A reads
-
Data Race, NO Race Condition (
transfer4example): Consider updating a boolean flagaccount.activity = true;without locks. Two threads might writetrueto this flag at the same time. This is technically a data race. However, if the only purpose of this flag is to know that some activity occurred (we don't care how many times), then the outcome is the same regardless of the timing. The final state is correct from the application's perspective. There is a data race, but no harmful race condition. Warning: Deliberately writing code with "benign" data races is extremely dangerous in C++ due to Undefined Behavior and is considered bad practice. We identify them to understand the concepts, not to write them.
This gives us a complete picture:
| Data Race | No Data Race | |
|---|---|---|
| Race Condition | The simple counter, transfer1, transferMoney |
The flawed bank transfer logic, transfer2 |
| No Race Condition | The "benign" activity flag, transfer4 |
A correctly synchronized program, transfer3 |
Test your understanding!
You have the following struct and two threads running concurrently.
struct Transform {
double position;
double rotation;
};
Transform shared_transform = {0.0, 0.0};
// Thread 1
shared_transform.position = 100.0;
// Thread 2
shared_transform.rotation = 90.0;
- Does this code contain a data race? Why or why not?
- Now consider this case:
// Thread 1
shared_transform.position += 10.0;
// Thread 2
shared_transform.position -= 5.0;
Does this second example contain a data race? Does it contain a race condition?
Show answer
-
No data race. Although the threads are accessing the same
struct, they are modifying different member variables (positionandrotation). Assuming these members do not share a memory location (which they won't, due to standard struct layout and padding), there are no concurrent accesses to the same memory location. -
Yes, this has both a data race and a race condition.
- Data Race: Both threads are reading and writing to the same memory location (
shared_transform.position) without any synchronization. This is the definition of a data race and is Undefined Behavior. - Race Condition: The final value of
shared_transform.positiondepends on the unpredictable order of execution. It could be 5.0, 10.0, or -5.0 depending on how the read-modify-write operations are interleaved. The program's correctness depends on timing, which is the definition of a race condition.
- Data Race: Both threads are reading and writing to the same memory location (
Conclusion
In this lesson, we've moved from the "how" of creating threads to the "what can go wrong." You've seen firsthand how unsynchronized access to shared data leads to incorrect and unpredictable program behavior.
Key Takeaways:
- Simple operations like
++are not atomic; they consist of separate read, modify, and write steps. - A race condition is a high-level flaw where program correctness depends on the timing of events. The result is logically incorrect.
- A data race is a low-level C++ violation where multiple threads access the same memory location without synchronization, and at least one access is a write. This is Undefined Behavior.
- Eliminating data races is a fundamental requirement for writing correct concurrent C++ programs. While this doesn't automatically fix all race conditions, it's the necessary first step.
You can now identify the most common and dangerous bugs in concurrent code. In our next lesson, we will finally learn how to prevent them. We will introduce our first synchronization primitive, std::mutex, which allows us to define a "critical section" and ensure that only one thread at a time can access shared data.