Skip to main content

Data Races and Race Conditions

A data race occurs when two or more threads access the same memory location concurrently, at least one access is a write, and there's no synchronization between them. Data races cause undefined behavior.

What is a Data Race?

// DATA RACE!
int counter = 0;

void increment() {
++counter; // Read, increment, write
}

// Thread 1 and Thread 2 both call increment()
// Load: 0 Load: 0
// Add: 1 Add: 1
// Store: 1 Store: 1
// Result: counter = 1 (should be 2!)

Common Data Race Examples

Example 1: Unsynchronized Counter

// WRONG: Data race
int globalCounter = 0;

void worker() {
for (int i = 0; i < 1000; ++i) {
++globalCounter; // DATA RACE!
}
}

int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();

std::cout << globalCounter << '\n'; // Undefined! (likely < 2000)
}

Example 2: Unsynchronized Flag

// WRONG: Data race
bool done = false;
int result = 0;

void producer() {
result = compute();
done = true; // DATA RACE on 'done'!
}

void consumer() {
while (!done) { // DATA RACE on 'done'!
// Wait
}
std::cout << result << '\n'; // May see old value!
}

Example 3: Vector Modification

// WRONG: Data race
std::vector<int> data;

void reader() {
if (!data.empty()) {
std::cout << data[0] << '\n'; // DATA RACE!
}
}

void writer() {
data.push_back(42); // DATA RACE!
}

Solutions to Data Races

Solution 1: Mutexes

#include <mutex>

int counter = 0;
std::mutex mtx;

void increment() {
std::lock_guard<std::mutex> lock(mtx);
++counter; // Protected
}

Solution 2: Atomics

#include <atomic>

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

void increment() {
++counter; // Atomic, no race
}

Solution 3: Thread-Local Storage

thread_local int counter = 0;  // Each thread has its own

void increment() {
++counter; // No race, thread-local
}

Solution 4: Immutable Data

const std::vector<int> data = {1, 2, 3, 4, 5};

void reader() {
std::cout << data[0] << '\n'; // No race, read-only
}

Read-Modify-Write Races

// WRONG: Even separate read/write can race
int value = 0;

void thread1() {
int temp = value; // Read
value = temp + 1; // Write
}

void thread2() {
int temp = value; // Read
value = temp + 2; // Write
}

Benign Races (Still UB!)

// WRONG: "Benign" race is still UB
bool initialized = false;

void initialize() {
if (!initialized) { // DATA RACE!
setup();
initialized = true; // DATA RACE!
}
}

// Correct: Use std::call_once
std::once_flag flag;

void initialize() {
std::call_once(flag, setup);
}

Detecting Data Races

ThreadSanitizer (TSan)

# Compile with TSan
g++ -fsanitize=thread -g program.cpp -o program

# Run
./program

# Output on data race:
# WARNING: ThreadSanitizer: data race
# Write of size 4 at 0x7b0400000000
# Previous write of size 4 at 0x7b0400000000

Example with TSan

#include <thread>

int counter = 0;

void increment() {
++counter; // TSan will detect this race
}

int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
return 0;
}

Common Misconceptions

Misconception 1: "But it works!"

// "Works" on your machine, UB on others
bool flag = false;

void thread1() {
flag = true;
}

void thread2() {
while (!flag); // May loop forever!
}
warning

Just because code "works" doesn't mean there's no data race. Compiler optimizations or different hardware can expose the race.

Misconception 2: "volatile fixes races"

// WRONG: volatile doesn't prevent data races!
volatile int counter = 0;

void increment() {
++counter; // STILL A DATA RACE!
}

// Correct: Use atomic
std::atomic<int> counter{0};

Misconception 3: "Aligned types are safe"

// WRONG: Even aligned types can race
alignas(64) int counter = 0;

void increment() {
++counter; // STILL A DATA RACE!
}

Safe Concurrent Patterns

Pattern 1: Message Passing

#include <queue>
#include <mutex>

template<typename T>
class ThreadSafeQueue {
std::queue<T> queue_;
std::mutex mutex_;

public:
void push(T value) {
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(std::move(value));
}

bool pop(T& value) {
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.empty()) return false;
value = std::move(queue_.front());
queue_.pop();
return true;
}
};

Pattern 2: Double-Checked Locking (Correct)

#include <atomic>
#include <mutex>

class Singleton {
static std::atomic<Singleton*> instance_;
static std::mutex mutex_;

public:
static Singleton* getInstance() {
Singleton* tmp = instance_.load(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(mutex_);
tmp = instance_.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton();
instance_.store(tmp, std::memory_order_release);
}
}
return tmp;
}
};

Pattern 3: Reader-Writer Lock

#include <shared_mutex>

class SharedData {
mutable std::shared_mutex mutex_;
std::vector<int> data_;

public:
int read(size_t index) const {
std::shared_lock lock(mutex_); // Multiple readers
return data_[index];
}

void write(size_t index, int value) {
std::unique_lock lock(mutex_); // Exclusive writer
data_[index] = value;
}
};

Best Practices

success

DO:

  • Use ThreadSanitizer during development
  • Protect shared data with mutexes or atomics
  • Prefer immutable data when possible
  • Use high-level concurrency primitives
  • Design for thread safety from the start
danger

DON'T:

  • Assume "it works" means no race
  • Use volatile for synchronization
  • Access shared data without protection
  • Ignore ThreadSanitizer warnings
  • Mix synchronized and unsynchronized access

Quick Reference

ScenarioSolution
Simple counterstd::atomic<int>
Complex data structurestd::mutex
Read-heavy workloadstd::shared_mutex
One-time initializationstd::call_once
Producer-consumerThread-safe queue