Skip to content
C++

Common Data Races and How to Avoid Them

A data race is not a bug in the ordinary sense — it is undefined behaviour. The compiler is allowed to assume it never happens, which means data-racy code can be miscompiled in ways that produce results bearing no relationship to what you wrote. Understanding what a data race is, how the most common patterns create them, and which C++ tools eliminate them is the foundation of correct concurrent code.

What is a data race?

The C++ standard's definition is precise: a data race occurs when at least two threads access the same memory location simultaneously, and at least one of those accesses is a write. The word "simultaneously" means without any synchronization mechanism between them — no mutex, no atomic, no happens-before relationship of any kind. When that condition is met, the standard says the program has undefined behaviour.

Undefined behaviour in this context is more dangerous than it first appears. It is tempting to think "worst case I get a torn read or stale value." But the compiler reasons about your program under the assumption that undefined behaviour does not occur. If it can prove that a particular execution path would require a data race to exist, it is free to eliminate that path entirely, hoist invariants across it, or produce any output it likes. Real compilers do this — an optimizer that sees a non-atomic increment of a shared variable may merge the repeated loads into one, cache the value in a register across loop iterations, or delete a check it believes can never fail. The bug is not just that you might observe the wrong value; the bug is that the entire program's behaviour becomes unpredictable.

// Classic data race: two threads incrementing a shared counter
int counter = 0;

void increment_1000() {
    for (int i = 0; i < 1000; ++i)
        ++counter;           // read-modify-write on a plain int — data race
}

std::thread t1(increment_1000);
std::thread t2(increment_1000);
t1.join();
t2.join();
// Expected: 2000. Actual: anything from ~1000 to 2000 — or worse.
// The compiler may optimize based on the assumption that counter is not
// concurrently modified, producing results that are not even in that range.

Pattern 1: non-atomic read-modify-write

The most recognizable data race is the increment example above. A plain ++counter is not a single indivisible operation — it compiles to a load, an add, and a store. Two threads can both load the same value, both increment it, and then both store back, losing one of the increments. This is called a lost update, and it is the hallmark of a missing synchronization around a read-modify-write sequence.

When the shared value is a simple integer and the operation is simple (increment, compare-and-swap, bit-flip), std::atomic<T> is the right fix. It makes the entire read-modify-write indivisible at the hardware level with no locking overhead. See the atomics lesson for the full API. For complex multi-step operations that must appear atomic as a group, you need a mutex instead.

// Fix: std::atomic makes the increment indivisible
#include <atomic>

std::atomic<int> counter{0};

void increment_1000() {
    for (int i = 0; i < 1000; ++i)
        ++counter;           // atomic increment — no data race
}
// Result is guaranteed to be 2000

Pattern 2: unprotected shared state

When multiple threads share state that is not a simple value — a std::vector, a map, a user-defined class — atomics are not sufficient. The fix is a mutex: a mutual exclusion primitive that ensures only one thread can be inside the protected region at a time. Every thread must call lock() before accessing the shared state and unlock() afterward. Any thread that tries to lock while another holds it will block until the lock is released.

The standard provides five mutex variants. For most use cases std::mutex is the right choice. std::recursive_mutex allows the same thread to lock multiple times (counting the nesting depth). The timed variants add try_lock_for and try_lock_until. The shared variants support reader-writer semantics covered below.

#include <mutex>
#include <vector>

std::vector<int> results;
std::mutex results_mutex;

void worker(int value) {
    // ... do computation ...
    results_mutex.lock();
    results.push_back(value);   // protected — only one thread at a time
    results_mutex.unlock();
}
// Works, but raw lock/unlock is fragile — an exception between them leaks the lock.
// Use RAII wrappers instead (see below).

Pattern 3: forgetting to unlock — use RAII locks

Calling unlock() manually is error-prone. If an exception is thrown between lock() and unlock(), the mutex stays locked forever — any other thread that tries to acquire it will block indefinitely. This is called a livelock or deadlock by omission. The standard solution is to wrap the mutex in a RAII guard that unlocks automatically when it goes out of scope, regardless of how the scope exits.

std::lock_guard is the simple form: it locks in its constructor and unlocks in its destructor. It cannot be unlocked early, cannot be moved, and covers the straightforward case where you want a mutex held for the duration of a scope. std::unique_lock is more flexible: it can be constructed without locking (deferred lock), unlocked and re-locked manually, and transferred to a different scope via move. Use lock_guard by default and reach for unique_lock only when you need its extra capabilities — notably when working with condition variables, which require the ability to temporarily release the lock.

#include <mutex>

std::vector<int> results;
std::mutex results_mutex;

void worker(int value) {
    // ... computation that might throw ...
    std::lock_guard<std::mutex> guard(results_mutex);  // locks here
    results.push_back(value);
    // guard destructor unlocks — even if push_back throws
}

// unique_lock: needed when you must unlock mid-scope
void worker_with_cv(int value, std::condition_variable& cv) {
    std::unique_lock<std::mutex> lock(results_mutex);
    results.push_back(value);
    lock.unlock();          // release before notify — avoids waking sleeping
    cv.notify_one();        // thread under contention
}

std::lock_guard

Simple RAII: locks on construction, unlocks on destruction. Non-copyable, non-movable. Use for the common case.

std::unique_lock

Advanced RAII: supports deferred locking, try_lock, timed lock, manual unlock/relock, and move. Required for condition variables.

std::shared_lock (C++14)

Reader lock for shared_mutex. Multiple threads can hold shared_lock simultaneously; a unique_lock (writer) is exclusive.

Pattern 4: wrong lock order — deadlocks

A deadlock occurs when two or more threads are each waiting for a resource that another holds. The classic scenario involves two mutexes: thread A locks mutex 1 and then tries to lock mutex 2, while thread B locks mutex 2 and then tries to lock mutex 1. Each thread is blocked waiting for the other to release, and neither can proceed. The program hangs indefinitely with no error, no exception, and no diagnostic.

The simplest fix is to always acquire multiple mutexes in the same order across all threads. When that is impractical (the order may depend on runtime data), the standard provides std::lock(), which takes any number of lockable objects and acquires them all atomically — meaning it will never acquire one and then block on another, avoiding the partial-lock state that causes deadlocks. After std::lock() returns, all mutexes are held; you then hand them to unique_locks with std::adopt_lock so they are released automatically.

std::mutex mx1, mx2;

// ✗ DEADLOCK: Thread A takes mx1 first; Thread B takes mx2 first.
void thread_a() {
    std::lock_guard<std::mutex> g1(mx1);   // A holds mx1
    std::lock_guard<std::mutex> g2(mx2);   // A waits for mx2 — B holds it
}
void thread_b() {
    std::lock_guard<std::mutex> g1(mx2);   // B holds mx2
    std::lock_guard<std::mutex> g2(mx1);   // B waits for mx1 — A holds it
}

// ✓ FIX: std::lock acquires both atomically — never partially locked
void thread_safe_a() {
    std::unique_lock<std::mutex> lk1(mx1, std::defer_lock);
    std::unique_lock<std::mutex> lk2(mx2, std::defer_lock);
    std::lock(lk1, lk2);    // acquire both in a deadlock-free way
    // both locks held; RAII releases on scope exit
}
void thread_safe_b() {
    std::unique_lock<std::mutex> lk1(mx1, std::defer_lock);
    std::unique_lock<std::mutex> lk2(mx2, std::defer_lock);
    std::lock(lk1, lk2);    // same call — order doesn't matter
}

Reader-writer: shared access for read-only threads

When shared data is read far more often than it is written, a plain std::mutex is unnecessarily restrictive — it prevents concurrent reads even though concurrent reads are safe (they cannot cause a data race). The reader-writer pattern uses std::shared_mutex (C++17) to distinguish between shared (read) locks and exclusive (write) locks. Any number of threads can hold a shared lock simultaneously; an exclusive lock is granted only when no other locks are held.

#include <shared_mutex>

std::unordered_map<int, std::string> cache;
std::shared_mutex cache_mutex;

// Multiple threads can read concurrently
std::string lookup(int key) {
    std::shared_lock<std::shared_mutex> lock(cache_mutex);   // shared (read) lock
    auto it = cache.find(key);
    return it != cache.end() ? it->second : "";
}

// Writers get exclusive access
void insert(int key, std::string value) {
    std::unique_lock<std::shared_mutex> lock(cache_mutex);   // exclusive (write) lock
    cache[key] = std::move(value);
}

Thread-safe initialization

Initialization is a common source of data races when one thread creates a resource while another thread simultaneously tries to use it. C++ provides several mechanisms that make initialization safe without requiring explicit locking in your code.

The cleanest is constexpr: anything computed at compile time is fully initialized before any thread runs. Static locals at block scope are guaranteed by C++11 to be initialized exactly once and in a thread-safe manner — the compiler generates the necessary locking around the initialization. For cases where the value cannot be static or compile-time — a resource that is created once at runtime based on runtime conditions — std::call_once with a std::once_flag provides the same guarantee: the callable is executed exactly once, even if multiple threads race to call it simultaneously.

#include <mutex>

// 1. constexpr: initialized at compile time — inherently thread-safe
constexpr int max_connections = 100;

// 2. Static local: C++11 guarantees exactly-once, thread-safe initialization
Database& get_database() {
    static Database db("production");   // initialized once on first call
    return db;                          // subsequent calls return same instance
}

// 3. std::call_once: for non-static, non-constexpr one-time initialization
std::once_flag config_flag;
Config* global_config = nullptr;

void ensure_config_loaded() {
    std::call_once(config_flag, []() {
        global_config = new Config("config.toml");   // called exactly once
    });
}
// All threads that call ensure_config_loaded() will either execute the lambda
// or block until the one executing it finishes. global_config is safe to use
// after the call returns.

Eliminating sharing: thread_local

The safest way to avoid a data race is to ensure threads do not share data in the first place. The thread_local storage specifier gives each thread its own independent copy of a variable. The variable behaves exactly like a regular static or global — accessible from anywhere its name is in scope — but accesses from different threads hit different storage. Because no two threads share the same memory location, no synchronization is required.

The lifetime of a thread-local variable begins when the thread starts and ends when the thread exits. This makes thread_local a good fit for per-thread caches, scratch buffers, random number generators, and any other context that is naturally per-thread and does not need to be shared.

#include <random>

// Each thread gets its own RNG — no mutex needed, no data race possible
thread_local std::mt19937 rng(std::random_device{}());

double random_double() {
    return std::uniform_real_distribution<>(0.0, 1.0)(rng);
}

// Each thread accumulates its own error count; merge at the end
thread_local int local_errors = 0;

void worker() {
    // ... do work, increment local_errors on failure ...
}
// After joining all threads, collect per-thread counts via a shared container
// (protected by a mutex, or filled by each thread before joining)

Condition variables: the two hazards

A condition variable allows one thread to wait until another signals that a condition has become true — a consumer waiting for the producer to add data to a queue, for example. While the thread is waiting, it releases the mutex (allowing the producer to acquire it), and when it is woken up it re-acquires the mutex before returning. The pattern seems straightforward, but there are two hazards that are easy to miss.

A spurious wakeup is a wakeup that occurs without anyone calling notify_one() or notify_all(). The POSIX specification explicitly permits them, and the C++ standard inherits that latitude. If your wait loop does not check the condition after waking, it may proceed even though the condition is still false. A lost wakeup is the opposite problem: the producer calls notify_one() before the consumer has entered its wait() call. That notification is lost, and the consumer waits indefinitely.

Both hazards are solved by the two-argument form of wait(), which takes a predicate. It checks the predicate before blocking (fixing lost wakeups) and re-checks it after each wakeup (fixing spurious wakeups). You should always use the predicate form.

#include <condition_variable>
#include <mutex>
#include <queue>

std::queue<int> work_queue;
std::mutex queue_mutex;
std::condition_variable queue_cv;
bool done = false;

// Producer
void producer() {
    for (int i = 0; i < 100; ++i) {
        {
            std::lock_guard<std::mutex> lock(queue_mutex);
            work_queue.push(i);
        }
        queue_cv.notify_one();
    }
    {
        std::lock_guard<std::mutex> lock(queue_mutex);
        done = true;
    }
    queue_cv.notify_all();
}

// Consumer — always use the predicate form of wait()
void consumer() {
    while (true) {
        std::unique_lock<std::mutex> lock(queue_mutex);
        // wait(lock, pred) is equivalent to:
        //   while (!pred()) { wait(lock); }
        // Handles both spurious wakeups and lost wakeups.
        queue_cv.wait(lock, []{ return !work_queue.empty() || done; });

        if (work_queue.empty()) break;   // done and queue is empty

        int item = work_queue.front();
        work_queue.pop();
        lock.unlock();
        process(item);
    }
}

// ✗ WRONG: no predicate — vulnerable to spurious and lost wakeups
void bad_consumer() {
    std::unique_lock<std::mutex> lock(queue_mutex);
    queue_cv.wait(lock);              // wakes spuriously or too late — disaster
    process(work_queue.front());      // may run when queue is empty
}

Choosing the right tool

Simple counter or flag shared across threads

Lock-free, minimal overhead. Covers inc/dec, CAS, bit operations.

std::atomic<T>

Complex shared state (container, multi-field struct)

Lock_guard for simple scope; unique_lock when you need deferred lock or condition variables.

std::mutex + std::lock_guard

Read-heavy shared data with occasional writes

Multiple readers allowed simultaneously; writer gets exclusive access.

std::shared_mutex + shared_lock/unique_lock

Acquiring two or more mutexes at once

Atomically acquires all; eliminates the wrong-order deadlock.

std::lock() with deferred unique_locks

One-time resource initialization

Static locals are thread-safe by C++11; call_once for non-static cases.

static local or std::call_once

Per-thread state that doesn't need sharing

Each thread gets its own copy — no synchronization needed at all.

thread_local

Thread waiting on a condition set by another thread

Always use the predicate form of wait() to handle spurious and lost wakeups.

std::condition_variable + predicate
Sign in to track progress