Multithreading

Race Conditions and Data Races

When multiple threads run concurrently, they can try to read and write to the same locations in memory. Without the correct synchronization, these reads and writes are a race condition: the program’s output depends on the order of operations, which is determined by thread interleaving.

This unpredictability means results can differ even with identical inputs.

A subtype of race conditions in C++ is known as a data race. A data race exists if all of the following conditions are true at the same time:

  • two or more threads access the same memory address

  • at least one of the accesses is a write or a read modify write

  • accesses are not ordered by a happens before relationship

  • accesses are not atomic

According to the C++ standard, any program with a data race is in a state of undefined behavior. Undefined behavior means there are no guarantees whatsoever: the program might output correct results, incorrect results, crash, or might appear to function for years and then break depending on compiler, optimization level, or hardware.

This means that a data race is never just a bug in logic, but a violation of C++’s rules.

Let's consider a simple shared counter being incremented by two different threads. The code for incrementing looks trivial:

counter = counter + 1;

However, in the CPU it is likely to become a three stage instruction:

  1. Load the value in counter into a register

  2. Add one to the register's value

  3. Store the register's value back into counter

It is possible for the compiler or processor to interleave these three steps in many ways if no synchronization occurs:

  • Thread A loads counter's value (initially zero)

  • Thread B loads counter's value (initially zero)

  • Thread A stores (one plus the register's value (0)), writing 'one' into counter

  • Thread B stores (one plus the register's value (0)), writing 'one' into counter

The result is that counter has the value 'one' while two increment instructions were issued. This is due to the unpredictable nature of thread execution.

#include <iostream>
#include <thread>
#include <vector>

int counter = 0;   // shared variable, deliberately unprotected

void increment()
{
    for (int i = 0; i < 100000; ++i)
        ++counter;
}

int main()
{
    // Start this function on a separate thread
    std::vector<std::thread> threads;

    for (int i = 0; i < 4; ++i)
        threads.emplace_back(increment);

    for (auto& t : threads)
        t.join();

    std::cout << "Final counter value: " << counter << '\n';
    return 0;
}

If four threads try to increment a global integer variable 100,000 times, the program outputs a result less than the expected 400,000. The behavior of programs that have data races is undefined, and "losing" an update is just one possible outcome.

Race condition is a general term referring to any program where behavior depends on timing, whereas a data race is specifically about concurrently accessing a memory location when it could result in incorrect behavior. All data races are race conditions, but not all race conditions are data races.

For example, if two threads are trying to read from shared variables, they might have a race condition that depends on the time they read the variables and get different values, but if neither thread is modifying the variables, then it is not a data race, as no write access is involved. Data races are treated as a much more severe issue in C++'s definition and are declared undefined behavior.

Testing for data races is futile; if the program does not behave as expected, the issue is a data race. If it does, then it's merely by chance.

Because data races result in undefined behavior, compilers are permitted to make any optimization, including those that depend on data races not happening.

You may witness program behavior that differs from machine to machine or even version to version of a compiler.

Only removal of data races not testing can guarantee the right behavior.

The C++ memory model defines a relationship called happens before between events, such as instructions. If an operation A happens before operation B, then any effects of A are guaranteed to be visible to B and neither instruction can be a part of a data race.

Within a thread, every instruction automatically occurs after any preceding instruction, thus establishing happens before relationships. For different threads, happens before requires explicit use of synchronization mechanisms like mutexes, condition variables, or atomically ordered operations.

In the counter example, no synchronization took place, thus no happens before relationships were established between increments, and a data race was formed.

  • Read modify write operations on a non atomic shared variable (e.g., the counter).

  • Concurrent reads from or writes to a mutable shared data structure (e.g., a std::list) without external locking.

  • Passing a reference/pointer to an object to a new thread, if the lifetime of the variable on the stack will end before the thread finishes referencing the object (lifetime race, frequently overlapping with data race).

  • Using standard containers (except for thread safe ones) in a multithreaded manner without external locking.

All these patterns violate the conditions for happens before, resulting in data races.

The fundamental requirement for eliminating data races is to ensure that for every conflicting pair of memory accesses, there is an established happens before relationship, or that accesses are performed atomically. Standard tools for managing this include mutexes (for critical sections) and atomic variables with defined memory ordering.

These tools are covered in subsequent articles.

0 comments

No comments yet. Be the first.

Add a comment

About CppLearn

CppLearn is an interactive, free reference for modern C++. In each lesson, you'll find out how the language works, rather than just lines of code that you can copy and paste. We cover concepts starting from beginner lessons on fundamentals and proceed to templates, move semantics, smart pointers, and areas that usually trap or scare C++ learners.