Skip to content

Data Races and Critical Sections

This section covers the formal definition of data races in the C++ memory model, their undefined Behavior consequences, critical sections, the distinction between data races and race conditions, Detection tools, synchronization costs, and practical fixes.

A data race [N4950 §6.9.4.2] occurs when two or more threads access the same memory location Concurrently, at least one of them performs a write, and there is no happens-before relationship Between the accesses. Formally, a data race is present when all three conditions hold:

\mathrm{Data Race \iff \exists\, m, t_1, t_2 : \mathrm{access(t_1, m, w) \wedge \mathrm{access(t_2, m, r/w) \wedge \neg\mathrm{happens-before(t_1, t_2) \wedge \neg\mathrm{happens-before(t_2, t_1)

Where mm is a scalar memory location, ww denotes a write, rr denotes a read, and happens-before Is the order relation defined in [N4950 §6.9.4.1].

:::caution Warning The compiler is free to assume no data races exist and may optimize accordingly, potentially Eliminating loads, stores, or reordering operations in ways that are surprising and Non-deterministic. :::

The consequences of a data race include but are not limited to [N4950 §6.9.4.2]:

  1. Torn reads/writes: A read or write of a non-atomic variable may observe or produce a partially updated value.
  2. Reordering: The compiler may reorder non-atomic accesses past synchronization points, since data-race-free programs are the only programs the standard guarantees behavior for.
  3. Optimization elimination: The compiler may cache a value in a register and never re-read from memory, or may elide a store entirely.

A critical section is a region of code that accesses shared mutable state. Only one thread Should execute within a critical section at a time to prevent data races. The mutual exclusion of Critical sections is the fundamental goal of synchronization primitives such as mutexes.

These terms are often confused but have distinct meanings:

PropertyData RaceRace Condition
Defined by the C++ standardYes [N4950 §6.9.4.2]No (informal)
Results in UBAlwaysNot necessarily
Related to memory modelYesNo
Fix mechanismAtomic operations or mutexesDepends on the logic

A data race is a formal term in the C++ memory model. A race condition is a broader, Informal term for any situation where the program”s outcome depends on the relative timing of Threads. A race condition can occur even with proper synchronization (e.g., two threads both check if (queue.empty()) before either pushes an element).

:::caution Warning Exhibits undefined behavior and may crash, produce incorrect results, or appear to work correctly Depending on the platform and compiler flags. Never write code like this in production. :::

#include <iostream>
#include <thread>
#include <vector>
int counter = 0;
void increment(int iterations) {
for (int i = 0; i < iterations; ++i) {
++counter; // DATA RACE: non-atomic write to shared variable
}
}
int main() {
constexpr int num_threads = 10;
constexpr int iterations = 100'000;
std::vector<std::jthread> threads;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back(increment, iterations);
}
// jthreads join automatically in destructor
std::cout << "Expected: " << num_threads * iterations << "\n";
std::cout << "Actual: " << counter << "\n";
return 0;
}

The expected result is 10×100000=100000010 \times 100\,000 = 1\,000\,000But the actual result will Be less because ++counter is not atomic. The increment operation consists of three machine Instructions:

  1. Load counter from memory into a register.
  2. Increment the register.
  3. Store the register back to memory.

Two threads can interleave these steps such that one increment is lost. For example:

Thread AThread B
load counter (0)
increment (1)load counter (0)
store counter (1)increment (1)
store counter (1)

After both threads complete, counter is 1 instead of 2. This is a lost update.

The concept of a “benign data race” was discussed in academic literature (e.g., Boehm 2012, “Benign Data Races: What Could Possibly Go Wrong?”) and the conclusion is clear: there is no such thing as A benign data race in C++.

Even data races that appear harmless in practice can cause problems because:

  1. Compiler optimizations. The compiler may assume no data races and optimize accordingly. A value loaded from memory may be cached in a register, causing subsequent loads to return stale data. A store may be elided if the compiler proves the value is overwritten later. These optimizations are valid under the “as-if” rule [N4950 §6.9.1] because a data race makes the program’s behavior undefined.

  2. Architecture effects. On x86, the hardware memory model is relatively strong (total store order), so many data races appear benign. On ARM, POWER, or RISC-V, the hardware may reorder loads and stores, making torn reads and stale values far more likely.

  3. Future-proofing. Code that “works” today with benign data races may break when compiled with a different optimization level, a different compiler version, or a different CPU architecture.

Caution: Warning Writes, use a mutex or std::atomic [N4950 §6.9.4.2].

ThreadSanitizer (TSan) is a compile-time instrumentation tool that detects data races at Runtime. It is available in GCC and Clang:

Terminal window
# Compile with ThreadSanitizer
g++ -std=c++23 -fsanitize=thread -g -O1 data_race_demo.cpp -o data_race_demo
./data_race_demo

TSan reports data races with precise source locations and stack traces:

==================
WARNING: ThreadSanitizer: data race (pid=12345)
Write of size 4 at 0x7f8a0c000010 by thread T2:
#0 increment(int) data_race_demo.cpp:8
#1 std::thread::_State_impl<...>::_M_run() ...
Previous write of size 4 at 0x7f8a0c000010 by thread T1:
#0 increment(int) data_race_demo.cpp:8
#1 std::thread::_State_impl<...>::_M_run() ...
Location is global 'counter' of size 4 at 0x7f8a0c000010
==================

How TSan works: TSan instruments every memory access at compile time, maintaining a shadow Memory state machine that tracks happens-before relationships between threads. When it detects a Conflicting access without a happens-before edge, it reports a data race.

Limitations of TSan:

  • Runtime detection only. TSan cannot detect data races that do not occur during the specific execution being analyzed. A data race that requires a particular interleaving may go undetected.
  • Performance overhead. TSan adds 5–15x runtime overhead and 5–10x memory overhead. Not suitable for production deployments.
  • False positives (rare). TSan can report benign data races in correctly synchronized code if the synchronization mechanism is not recognized (e.g., custom spinlocks using std::atomic_flag).
  • No guarantee of completeness. TSan is sound but not complete — it may miss races that require specific interleavings.

Other tools:

ToolMethodStrengths
ThreadSanitizer (TSan)Compile-time instrumentationPrecise, low false-positive rate
Helgrind (Valgrind)Dynamic binary instrumentationNo recompilation needed, works on binaries
Intel InspectorHardware-based samplingLow overhead, production-capable
Clang Thread Safety AnalysisStatic analysis (annotations)Catches races at compile time

Synchronization is not free. Understanding the performance costs helps you make informed decisions About when and how to synchronize.

Modern CPUs use a cache coherence protocol (MESI or variant) to keep cached copies of the same Memory line consistent across cores. When a mutex is contended, the cache line containing the mutex Bounces between cores, causing:

  1. Invalidation traffic. Core A acquires the mutex, invalidating Core B’s cached copy. Core B later tries to acquire the mutex, invalidating Core A’s copy. Each invalidation costs ~40–100 cycles on modern x86.
  2. Bus traffic. Cache line invalidation and fetch generate traffic on the inter-core bus, consuming bandwidth that other cores could use for useful work.
  3. Pipeline stalls. While waiting for a cache line to arrive from another core, the CPU pipeline stalls.

False sharing occurs when two independent variables reside on the same cache line ( 64 Bytes) and are accessed by different threads. Even though the variables are logically independent, The hardware treats them as a single unit:

#include <thread>
#include <vector>
#include <iostream>
struct Counters {
int a;
int b;
// a and b are 4 bytes apart — same cache line (64 bytes)
};
Counters counters{};
void increment_a(int iterations) {
for (int i = 0; i < iterations; ++i) ++counters.a;
}
void increment_b(int iterations) {
for (int i = 0; i < iterations; ++i) ++counters.b;
}
int main() {
constexpr int iters = 10'000'000;
auto t1 = std::jthread(increment_a, iters);
auto t2 = std::jthread(increment_b, iters);
// Performance degraded by cache line bouncing on 'counters'
std::cout << counters.a << " " << counters.b << "\n";
}

Fix: Pad the variables to separate cache lines:

struct alignas(64) PaddedCounterA {
int value;
char padding[60]; // fill the rest of the 64-byte cache line
};
struct alignas(64) PaddedCounterB {
int value;
char padding[60];
};
PaddedCounterA counter_a{};
PaddedCounterB counter_b{};
void increment_a(int iterations) {
for (int i = 0; i < iterations; ++i) ++counter_a.value;
}
void increment_b(int iterations) {
for (int i = 0; i < iterations; ++i) ++counter_b.value;
}

:::tip Tip Prevents the next counter from sharing the same line. On systems with 128-byte cache lines, use alignas(128) and adjust the padding accordingly. :::

An uncontended lock (no thread is waiting) costs ~25–50 cycles on x86. A contended lock Requires a kernel context switch, costing ~1,000–10,000 cycles depending on the OS and scheduler. The difference is two orders of magnitude.

Guideline: Minimize critical section duration. Move as much work as possible outside the lock. Prefer lock-free data structures for high-contention scenarios.

std::atomic supports multiple memory orderings [N4950 §6.9.4.2]. The default is memory_order_seq_cstWhich provides the strongest guarantees but also the highest overhead. Weaker orderings can be used when full sequential consistency is not required.

Relaxed atomics guarantee atomicity (no torn reads/writes) but provide no ordering guarantees With respect to other memory operations. They are sufficient for simple counters and statistics Where the exact order of updates does not matter:

#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
std::atomic<int> relaxed_counter{0};
void increment_relaxed(int iterations) {
for (int i = 0; i < iterations; ++i) {
relaxed_counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
constexpr int num_threads = 10;
constexpr int iterations = 1'000'000;
std::vector<std::jthread> threads;
for (int i = 0; i < num_threads; ++i)
threads.emplace_back(increment_relaxed, iterations);
// No data race: all accesses are atomic
// The final value is guaranteed to be correct (10,000,000)
std::cout << "Counter: " << relaxed_counter.load(std::memory_order_relaxed) << "\n";
}

When memory_order_relaxed is sufficient:

  • Simple counters and statistics where you only need the final value.
  • Progress indicators and metrics that are read occasionally.
  • Bit flags that are set independently by different threads.

When memory_order_relaxed is NOT sufficient:

  • Producer-consumer patterns where the consumer must see the data written before the flag is set.
  • Reference counting (must use memory_order_acq_rel to prevent use-after-free).
  • Any pattern where one thread’s write must be visible to another thread before a flag is checked.
Memory OrderGuaranteesOverhead (x86)Use Case
memory_order_relaxedAtomicity onlyMinimal (no fence)Simple counters, statistics
memory_order_acquireNo reads/writes reordered before the loadMinimal (compiler fence on x86)Guard reads, flag checks
memory_order_releaseNo reads/writes reordered after the storeMinimal (compiler fence on x86)Guard writes, publishing data
memory_order_acq_relBoth acquire and releaseModerateReference counting
memory_order_seq_cstTotal order across all threadsFull memory fenceDefault, when in doubt

:::caution Warning memory_order_release are effectively free (the hardware provides the ordering). On ARM, POWER, and RISC-V, these orderings emit explicit memory barrier instructions and have real cost. Always measure Before optimizing memory orderings — memory_order_seq_cst is the safest default. :::

Consider a real-world pattern: a lazily-initialized singleton accessed from multiple threads.

#include <string>
class Config {
static Config* instance_;
std::string db_host_;
int port_;
Config() : db_host_("localhost"), port_(5432) {}
public:
static Config* get() {
if (!instance_) { // RACE: read of instance_
instance_ = new Config(); // RACE: write of instance_
}
return instance_;
}
const std::string& db_host() const { return db_host_; }
int port() const { return port_; }
};
Config* Config::instance_ = nullptr;

Two threads calling Config::get() simultaneously can both see instance_ == nullptrBoth Allocate a ConfigAnd both write to instance_. This is a data race on instance_ and also Causes a memory leak (one of the two allocations is overwritten and never freed).

#include <string>
#include <mutex>
class Config {
static Config* instance_;
static std::once_flag init_flag_;
std::string db_host_;
int port_;
Config() : db_host_("localhost"), port_(5432) {}
public:
static Config* get() {
std::call_once(init_flag_, [] {
instance_ = new Config();
});
return instance_;
}
const std::string& db_host() const { return db_host_; }
int port() const { return port_; }
};
Config* Config::instance_ = nullptr;
std::once_flag Config::init_flag_;

std::call_once guarantees that the callable is invoked exactly once, even if multiple threads call It concurrently [N4950 §33.4.4]. The internal synchronization is handled by the std::once_flag.

The Fix: Meyer’s Singleton (C++11 and later)

Section titled “The Fix: Meyer’s Singleton (C++11 and later)”
class Config {
std::string db_host_;
int port_;
Config() : db_host_("localhost"), port_(5432) {}
public:
static Config& get() {
static Config instance; // Thread-safe since C++11 [N4950 §6.9.2.2]
return instance;
}
const std::string& db_host() const { return db_host_; }
int port() const { return port_; }
};

Since C++11, the initialization of function-local static variables is guaranteed to be thread-safe [N4950 §6.9.2.2]. The compiler emits guard variables and implicit synchronization to ensure that Exactly one thread performs the initialization, and all other threads wait until it completes. This Is the preferred idiom in modern C++.

Assuming volatile prevents data races. volatile in C++ tells the compiler not to optimize Away accesses to a variable. It does not provide atomicity, ordering, or thread safety. A volatile int accessed by two threads without synchronization is still a data race with undefined Behavior. volatile is for memory-mapped I/O and signal handlers, not concurrency [N4950 §6.9.1].

Using bool flags instead of atomics. A plain bool used as a signal between threads is a data Race. Even on architectures where bool loads and stores are atomic (x86), the compiler May cache the value in a register or reorder accesses. Use std::atomic<bool>:

// Bad: data race
bool ready = false;
// Thread 1: ready = true;
// Thread 2: while (!ready) {}
// Good: no data race
std::atomic<bool> ready{false};
// Thread 1: ready.store(true, std::memory_order_release);
// Thread 2: while (!ready.load(std::memory_order_acquire)) {}

Locking the wrong granularity. A single global mutex for all shared state is correct but creates Unnecessary contention. A per-object mutex reduces contention but increases the risk of deadlock if Locks are acquired in inconsistent order. Choose the granularity based on your access patterns and Measure.

Forgetting that const does not imply thread safety. A const member function promises not to Modify the object’s logically const state, but it does not provide synchronization. If a const Member function reads shared mutable state (e.g., mutable members), it can still participate in a Data race:

class ThreadUnsafeCache {
mutable std::string cache_; // mutable to allow modification in const methods
mutable std::mutex mtx_;
public:
std::string get() const {
// Without mtx_.lock(), reading cache_ is a data race
// if another thread calls set() concurrently.
std::lock_guard<std::mutex> lock(mtx_);
return cache_;
}
void set(const std::string& val) const {
std::lock_guard<std::mutex> lock(mtx_);
cache_ = val;
}
};

This topic covers the essential concepts and techniques related to data races and critical sections, including key principles and practical applications.

Key concepts include:

  • core concepts and definitions
  • key principles and frameworks
  • practical applications
  • common techniques and methods
  • evaluation and critical analysis

A thorough understanding of these concepts, combined with regular practice and review, is essential for mastery of this topic.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.