Skip to main content

Welcome to a Grasp lesson

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

Lesson 5

Detecting Race Conditions in Code

Recommended resources

In our last lesson, we successfully built a multi-threaded program to perform a parallel array summation. We saw two ways to aggregate the results, one of which involved a "mutex" to protect a shared global variable. You might have wondered why that lock was necessary. We used it to prevent a subtle but critical bug that can arise when multiple threads interact.

Today, we will dissect that bug. This lesson focuses on formally defining and identifying race conditions in concurrent programs. You will learn what causes them, recognize common patterns that lead to them, and develop a systematic approach to analyzing code for these elusive but dangerous errors. Mastering this skill is fundamental to writing correct and reliable parallel software.

What is a Race Condition?

At its core, a race condition occurs when the correctness of a program depends on the unpredictable timing or relative ordering of events, such as the scheduling of threads by the operating system. When multiple threads access and manipulate shared data, and at least one of them is writing to the data, the final outcome can become non-deterministic and, often, incorrect.

Let's start with a formal definition and a classic analogy.

What Is a Race Condition? | Baeldung on Computer Science

This article from Baeldung on Computer Science provides a clear and concise definition of race conditions.

Read the introduction and the section titled "Race Condition". The bank transfer example is a perfect illustration of how interleaved operations can corrupt a shared state, even when each step seems logical on its own.

The key takeaway is that operations on shared resources are not "atomic" by default. An operation is atomic if it appears to the rest of the system to occur instantaneously and indivisibly. When an operation is not atomic, the OS can pause a thread in the middle of it and switch to another thread, leading to chaos.

Let's revisit the parallel summation problem from our last lesson. The following video demonstrates exactly what happens if we try to implement it with a shared global variable without the protection of a mutex.

What are Race Conditions?

This video from CodeVault, titled "What are Race Conditions?", perfectly illustrates the problem using an example very similar to our previous lesson's work.

Watch the initial segment, from the setup to the reveal of the race condition. Notice how the program works correctly with a small number of iterations but produces an incorrect result when the workload increases, making the chance of an inopportune thread switch much higher.

The image below visualizes this exact scenario. Both threads read the same initial value, compute their result based on this stale data, and then one thread's write is overwritten by the other. The work of one thread is effectively lost.

Two threads attempt to add 10 to a shared variable X, which has an initial value of 5. Due to concurrent processing, both read X=5, both calculate 15, and both write 15 back. The final result is 15, not the expected 25.

The Anatomy of a Race: Read-Modify-Write

Why does a simple increment operation like mails++ cause such a problem? It's because that single line of C code is not a single instruction for the CPU. It's a sequence of operations. This is often called a read-modify-write sequence.

  1. Read: The current value of the variable is read from memory into a CPU register.
  2. Modify: The value in the register is incremented.
  3. Write: The new value from the register is written back to memory.

A race condition occurs when the thread scheduler suspends one thread after it reads the value but before it writes the new one back, and then allows another thread to run through the same sequence.

The same CodeVault video provides an excellent explanation of this breakdown, even showing the underlying assembly code. This connection between high-level code and machine-level execution is crucial for understanding concurrency.

What are Race Conditions?

Continuing with the same video, we'll now look at the "why".

Watch the next three segments: The three operations that make up an increment. The table demonstration, which clearly visualizes how the interleaving of these three steps leads to a lost update. The assembly code. As a CSE student, you'll appreciate seeing how a single C statement expands into multiple machine instructions, creating the window of opportunity for a race.


A Framework for Identifying Race Conditions

Now that you understand what a race condition is and why it happens, how do you spot one in a complex program? It requires a systematic approach. The Stanford University CS110 course provides a helpful "Race Condition Checklist".

[PDF] CS110 Lecture 08: Concurrency and Race Conditions

These lecture slides from Stanford's CS110 course offer a practical, step-by-step method for analyzing concurrent code.

On slide 20, find the section titled "The Race Condition Checklist". This three-step process is your guide: Identify shared data: Find any data (global variables, heap-allocated objects) that is accessible by more than one thread or process. Document problematic orderings: Think about the different ways the scheduler could interleave the execution of threads accessing that shared data. Look for an ordering that breaks the program's implicit assumptions and leads to an incorrect result. Force expected orderings: This involves using synchronization primitives (like the mutex from our last lesson) to prevent the problematic orderings. We will cover this in detail in future lessons.

For now, we'll focus on the first two steps: identifying shared data and finding the "bad" interleaving.

Pattern 1: Read-Modify-Write

This is the pattern we've already studied. Any time you see code that reads a shared variable, modifies it, and writes it back without synchronization, you have a potential race condition. The counter++ example is the canonical case.

Pattern 2: Check-Then-Act

This is another extremely common and dangerous pattern. It occurs when a program first checks a condition on a shared resource and then takes an action based on the result of that check. The race occurs if the state of the shared resource can change between the "check" and the "act". This is also known as a Time-of-check to time-of-use (TOCTOU) bug.

The Baeldung article you read earlier discusses this pattern. Let's see a code example. The following reading uses processes and signals (which you're familiar with from your OS coursework), but the principle applies identically to threads.

[PDF] CS110 Lecture 08: Concurrency and Race Conditions

Let's return to the Stanford lecture slides to see a check-then-act race condition in the wild.

Start by reading slide 15, "Concurrency", for a definition. Now, look at slides 16 and 17, which present the job-list-broken.c example. The program adds a process ID to a "job list" after it has been created. The signal handler, which runs concurrently, removes the job from the list. The race is that the SIGCHLD signal can be delivered and handled before the main program has a chance to print "Job ... added to job list". The check is implicitly "is the child running?" and the act is "add it to the list". The state changes (child terminates) between the check and the act. Finally, briefly look at slide 18, "job-list-fixed.c". It shows how blocking the signal creates a critical section—a block of code that must be executed atomically—to solve the race. This is analogous to how we used a mutex in our previous lesson.


A Note on Terminology: Data Race vs. Race Condition

You will often hear the terms "race condition" and "data race" used interchangeably, but there is a subtle and important distinction.

  • A data race is a specific, low-level condition defined by the programming language or memory model. It occurs when two or more threads concurrently access the same memory location without synchronization, and at least one of those accesses is a write. Our counter++ example is a perfect data race.
  • A race condition is a broader, semantic-level bug. It means the program's correctness depends on timing.

Most data races cause race conditions. However, you can have a race condition without a data race. The "check-then-act" bug on a file system (if file exists, then open it) is a race condition because another process could delete the file in between, but it's not a data race because there isn't a single memory location being accessed improperly.

Understanding this distinction is key to diagnosing bugs correctly. Tools can often automatically detect data races, but finding broader race conditions requires careful thought about your program's logic.

The following video explains this difference very clearly.

How to debug Linux multi threaded code - Greg Law - Meeting C++ 2024

In this excerpt from a talk at Meeting C++ 2024, Greg Law clarifies the distinction between data races and race conditions.

Watch the segment from this point. The Venn diagram he presents is a perfect mental model for the relationship between bugs, non-deterministic bugs, race conditions, and data races.


The Challenge of Detection: "Heisenbugs"

Race conditions are notoriously difficult to find and debug because they are non-deterministic. They may only appear under heavy load, on certain hardware, or one time in a million executions. When you try to debug them by adding print statements or attaching a debugger, you change the timing of the program, which can make the bug disappear. These are often called "Heisenbugs", after the Heisenberg Uncertainty Principle in physics.

Because manual detection is so difficult, specialized tools have been developed. These tools instrument your code to watch memory accesses and can report data races as they happen. One of the most common is ThreadSanitizer (TSan), which is built into modern compilers like GCC and Clang.

You don't need to use it right now, but it's essential to know that such tools exist.

How to debug Linux multi threaded code - Greg Law - Meeting C++ 2024

Let's see a quick demo of ThreadSanitizer from the same talk.

Watch how the presenter compiles his C++ code with the -fsanitize=thread flag and immediately gets a detailed report about the data race in his code, including the exact lines where the conflicting accesses occurred. This happens from this segment.


Conclusion

In this lesson, we have moved from encountering a concurrency bug to being able to formally define and identify it. You learned that a race condition makes a program's behavior dependent on unpredictable thread scheduling. We saw that they are often caused by non-atomic operations, such as the read-modify-write and check-then-act patterns. You now have a systematic checklist for analyzing code: identify shared data and search for problematic interleavings. We also clarified the important distinction between general race conditions and specific data races.

While identifying these bugs is a critical skill, it is only half the battle. Our next lesson will focus on prevention. We will explore fundamental thread-safe design patterns, such as using immutable data and thread-local storage, to write code that is correct by construction and avoids race conditions from the start.