C++ Data Races: When to Use Atomics Instead of Mutexes

Introduction: “When should I use atomics instead of a mutex?”

Series #7 compressed for interviews

The Mutex guide (#7-2) and the Atomic Operations guide cover the basics. This article prepares you for follow-up interview questions: the definition of a data race, mutex vs atomic trade-offs, deadlock fixes, and CAS.

What this article covers:

  • Data race (C++ standard definition) and why synchronization exists
  • Mutex: critical section, one thread at a time
  • Atomic: single-variable atomic operations without a lock
  • Mutex vs atomic — how to choose
  • Deadlock and fixes (lock ordering, std::lock)
  • CAS (Compare-And-Swap: atomically update memory only if it matches an expected value; used in lock-free algorithms) After reading:
  • You can state the precise definition of a data race and recognize real bugs.
  • You can choose between mutex and atomic appropriately.
  • You can apply practical patterns to avoid deadlocks.
  • You understand CAS and compare_exchange_*.

What is a data race?

Definition in the C++ standard

  • A data race occurs when two threads access the same memory location, at least one is a write, the accesses are not ordered by a happens-before relation from synchronization, and they are not both atomic according to the rules.
  • A program with a data race has undefined behavior in C++. “Sometimes wrong” often comes from data races.

Synchronization tools

  • Mutex: only the thread holding the lock enters the protected region; shared accesses do not overlap.
  • Atomic: reads/writes/read-modify-writes on one variable are atomic, removing data races on that variable for those operations.
  • Condition variables, etc.: coordinate waiting and notification. In interviews you can say: “A data race is unsynchronized conflicting access on the same location; it’s UB. Use mutexes, atomics, or other synchronization to prevent it.”

Problem scenarios: real data races

Scenario 1: Broken counter

Several worker threads increment one shared counter; after batch processing, totals diverge from the database by tens of thousands.

// Bad: counter++ is not atomic
#include <thread>
#include <iostream>
int counter = 0;
void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter++;  // read-modify-write is not atomic → data race
    }
}
int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << "\n";  // not 200000!
    return 0;
}

Why it breaks: counter++ splits into load, add, store. If two threads interleave after reading the same value, one update can be lost.

sequenceDiagram
    participant T1 as Thread 1
    participant Mem as Memory
    participant T2 as Thread 2
    Mem->>T1: read: 0
    Mem->>T2: read: 0
    T1->>T1: +1 → 1
    T2->>T2: +1 → 1
    T1->>Mem: write: 1
    T2->>Mem: write: 1
    Note over Mem: result 1 (expected 2)

Scenario 2: Inventory bug (check-then-act)

E-commerce: concurrent purchases can drive stock negative.

int stock = 100;
void purchase(int quantity) {
    if (stock >= quantity) {
        // Another thread may run here
        stock -= quantity;
    }
}

Cause: The check and update are not atomic—classic check-then-act. Protect both with one mutex.

Scenario 3: Flag/data mismatch

One thread writes data then sets a flag; another reads the flag then data. The compiler or CPU may reorder stores so the flag becomes visible before the data.

bool ready = false;
int data = 0;
void producer() {
    data = 42;
    ready = true;  // reordering possible
}
void consumer() {
    while (!ready)
        ;
    std::cout << data << "\n";  // may still see 0
}

Fix: std::atomic<bool> ready and atomic data, or protect both with a mutex.

Scenario 4: Broken double-checked locking

Naïve DCLP before C++11 can publish a pointer before the object is fully constructed. Fix in modern C++: std::call_once or Meyers’ singleton with a static local variable.

Scenario 5: Caches and visibility

Without synchronization, a write on one core may not be visible immediately to another core’s cache. Atomics/mutexes insert the needed memory ordering for visibility.


Role and limits of a mutex

Role

  • Ensures only one thread at a time runs the critical section.
  • Good when multiple variables or complex invariants must be updated together (maps, queues).
sequenceDiagram
    participant T1 as Thread 1
    participant M as mutex
    participant T2 as Thread 2
    T1->>M: lock()
    M-->>T1: acquired
    T2->>M: lock()
    Note over T2: waiting
    T1->>M: critical section
    T1->>M: unlock()
    M-->>T2: acquired
    T2->>M: critical section

Protecting a counter with a mutex

#include <mutex>
#include <thread>
#include <iostream>
int counter = 0;
std::mutex mtx;
void increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        counter++;
    }
}

unique_lock and try_lock

Use unique_lock with try_to_lock or timed_mutex::try_lock_for for conditional or timed locking.

Mutex types (summary)

MutexUse
std::mutexDefault mutual exclusion
std::recursive_mutexSame thread may lock multiple times
std::timed_mutextry_lock_for / try_lock_until
std::shared_mutex (C++17)Many readers, one writer

Limits

  • Holding locks too long serializes work and hurts throughput.
  • Multiple locks with inconsistent order → deadlock.

Role and limits of atomics

Role

  • Makes reads/writes/RMWs on one variable atomic without a mutex.
  • Often cheaper than a mutex for simple hot counters under contention.

Limits

  • One variable only—invariants spanning multiple fields need a mutex (or a carefully designed lock-free structure).
  • Splitting a logical update into multiple atomic ops can still race.

Counter with std::atomic

#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter{0};
void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter.fetch_add(1);
    }
}

memory_order (interview cheat sheet)

Default is seq_cst. Use acquire/release for handoff synchronization; relaxed only for independent counters where no cross-variable ordering is required.


Mutex vs atomic: how to choose

Prefer a mutex when

  • You must update multiple variables together.
  • Read-check-write must be one atomic step logically.
  • You modify complex containers concurrently.

Prefer atomics when

  • A single variable (counter, flag, pointer) suffices.
  • You want to reduce lock overhead on a hot counter.
flowchart TD
    subgraph choose[Mutex vs atomic]
        A[What are you protecting?] --> B{Single variable?}
        B -->|Yes| C{Simple op?}
        C -->|Yes| D[Atomic]
        C -->|No| E{Complex condition?}
        E -->|Yes| F[Mutex]
        B -->|No| F
        D --> G[Counters, flags]
        F --> H[Maps, queues, multi-field updates]
    end

One-liner for interviews: Use atomics for one variable and simple RMW; use a mutex for multiple fields or complex conditions.


Deadlocks and fixes

A deadlock is two threads each holding one lock and waiting forever for the lock the other holds (T1 holds A and waits for B while T2 holds B and waits for A). The interview answer has three parts:

  • Fixed lock order: every thread acquires A before B, so a circular wait cannot form. It works for any number of mutexes but is only a convention.
  • Acquire together: std::scoped_lock lock(mutexA, mutexB); (C++17), or std::lock(mutexA, mutexB) followed by lock_guards with std::adopt_lock, uses a deadlock-avoidance algorithm, so argument order stops mattering.
  • Keep critical sections small and never call unknown code (callbacks, user-supplied virtuals) while holding a lock.

The worked transfer(from, to) example, why the bug only appears under load, and how to read a hung process in GDB are in std::mutex, lock_guard, unique_lock and scoped_lock.


CAS (Compare-And-Swap)

Concept

CAS atomically writes a new value only if the current value equals expected; otherwise it leaves the value unchanged and updates expected for retry loops.

In C++

Use std::atomic::compare_exchange_strong / compare_exchange_weak.

// Lock-free spinlock sketch
#include <atomic>
std::atomic<bool> lock_flag{false};
void lock() {
    bool expected = false;
    while (!lock_flag.compare_exchange_strong(expected, true)) {
        expected = false;
    }
}
void unlock() {
    lock_flag.store(false);
}

Strong vs weak: weak may spuriously fail on some ISAs—fine in retry loops; strong is simpler for single-shot checks.


Production patterns

Pattern 1: thread-safe Meyers’ singleton (C++11)

class Config {
public:
    static Config& instance() {
        static Config cfg;  // thread-safe one-time init
        return cfg;
    }
    Config(const Config&) = delete;
    Config& operator=(const Config&) = delete;
private:
    Config() = default;
};

Pattern 2: std::call_once for heavy initialization

#include <mutex>
std::once_flag init_flag;
Database* db = nullptr;
void initDatabase() {
    std::call_once(init_flag, []{
        db = new Database("connection_string");
        db->connect();
    });
}

Pattern 3: producer–consumer queue (mutex + condition variable)

Use std::mutex, std::condition_variable, and notify_one() outside the lock when possible for performance.

Pattern 4: read-mostly cache with shared_mutex (C++17)

Many concurrent readers, rare writers: std::shared_lock for reads, std::unique_lock for writes.

Pattern 5: per-thread counters + periodic merge

Accumulate in thread_local counters, then fetch_add into a global atomic with memory_order_relaxed to reduce contention.

Pattern 6: graceful shutdown flag

std::atomic<bool> shutdown_requested{false};
void workerThread() {
    while (!shutdown_requested.load(std::memory_order_acquire)) {
        doWork();
    }
}
void requestShutdown() {
    shutdown_requested.store(true, std::memory_order_release);
}

Common mistakes

  1. Atomic flag + non-atomic payload still data-races—make both atomic or use one mutex.
  2. Check-then-act split across atomics—use CAS or a mutex.
  3. I/O or network calls under a mutex—copy out, release lock, then I/O.
  4. Inconsistent lock order across threads.
  5. Recursive lock on std::mutex—use std::recursive_mutex if you must re-enter.
  6. Missing unlock on early return—prefer RAII (lock_guard / unique_lock).
  7. No ThreadSanitizer in CI—add -fsanitize=thread builds.
g++ -std=c++17 -fsanitize=thread -g -o race_test race_test.cpp
./race_test

Performance: mutex vs atomic

Rough expectations (machine dependent): a hot atomic counter can be several times faster than the same logic guarded by a mutex under heavy contention; when contention is low and sections are tiny, mutex overhead may be small—always benchmark your workload. Tips: minimize lock scope; consider shared_mutex for read-heavy paths; use cache-line padding (alignas(64)) to avoid false sharing between atomics accessed by different threads.


Interview Q&A

Q: What is a data race?
Conflicting unsynchronized accesses on the same location where at least one is a write → UB; fix with mutex/atomic/etc.

Q: When use atomics instead of mutex?
Single variable, simple RMW; otherwise mutex.

Q: Avoid deadlocks?
Single lock order or std::lock for multiple mutexes.

Q: What is CAS?
Atomic compare-and-swap; compare_exchange_* in C++.

Q: When tune memory_order?
Start with defaults; relax only after profiling shows atomic overhead matters—use relaxed mainly for standalone counters.


Mutex or atomic: the short answer

  • Data race → UB; synchronize with mutex or atomic (or other facilities).
  • Mutex protects multi-statement invariants; atomics protect single variables efficiently when appropriate.
  • Deadlock → consistent ordering or std::lock.
  • CAS implements conditional atomic updates for lock-free structures.

Next: C++ Lock-Free Programming (#34-3) Previous: C++ Smart Pointers & Circular Reference Solutions


FAQ (extended)

Q. Is atomic-only synchronization always faster than a mutex?

A. Not always. For long critical sections or invariants that span several variables, a mutex is simpler and safer. Atomics fit short, single-variable operations such as counters and flags.

Q. What is the most practical rule to prevent deadlocks?

A. Acquire locks in a single global order, keep lock hold time short, and use std::lock when you must take several mutexes at once. Document the lock ordering at design time so new code follows it.