Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 17

Detecting Race Conditions

Recommended resources

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.

What are Race Conditions?

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:

  1. Read: Load the current value of the variable from main memory into a CPU register.
  2. Modify: Increment the value within the CPU register.
  3. 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.

Race Condition Example with Shared Balance
This diagram shows a classic race condition. Both processes read the same initial balance. Process 2 writes its result first. Then, Process 1, unaware of Process 2's update, overwrites the balance with a value calculated from the old, stale data it read initially, leading to data corruption.

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.

What are Race Conditions?

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.

Race Condition vs. 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:

    1. Two or more threads access the same memory location concurrently.
    2. At least one of the accesses is a write.
    3. The threads are not using any exclusive synchronization mechanism (like a lock) to protect the access.
    4. 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 (transfer2 example): Imagine each individual read and write to the account balances is protected (e.g., using std::atomic, which we'll cover later). This eliminates the data race. However, the overall transaction logic is flawed:

    1. Thread A reads account_from.balance. It's sufficient.
    2. Context Switch. Thread B runs a full transfer, draining account_from.
    3. 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.
  • Data Race, NO Race Condition (transfer4 example): Consider updating a boolean flag account.activity = true; without locks. Two threads might write true to 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;
  1. Does this code contain a data race? Why or why not?
  2. 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
  1. No data race. Although the threads are accessing the same struct, they are modifying different member variables (position and rotation). 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.

  2. 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.position depends 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.

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.