Skip to content

Race Conditions & Mutexes

Race conditions occur when multiple threads access shared data simultaneously, leading to unpredictable and incorrect behavior.

What is a Race Condition?

A race condition happens when the outcome of a program depends on the timing of thread execution. The same code can produce different results on different runs.

Example: The Increment Problem

Consider this simple counter increment:

cpp
int counter = 0;

void increment_counter() {
    int temp = counter;  // Read current value
    temp = temp + 1;     // Increment
    counter = temp;      // Write back
}

What happens with 2 threads?

cpp
Thread A: reads counter (0)
Thread B: reads counter (0) 
Thread A: increments temp (1)
Thread B: increments temp (1)
Thread A: writes counter (1)
Thread B: writes counter (1)  // Lost update!

Expected result: 2 Actual result: 1 (race condition!)

Why Race Conditions Happen

  1. Non-atomic operations: Reading, modifying, and writing isn't atomic
  2. Interleaved execution: Threads can switch between any instruction
  3. Shared state: Multiple threads accessing the same data
  4. No synchronization: No coordination between threads

The Solution: Mutexes

A mutex (mutual exclusion) ensures only one thread can access a critical section at a time.

std::mutex Basics

cpp
#include <mutex>
#include <thread>

std::mutex mtx;
int counter = 0;

void increment_counter() {
    mtx.lock();           // Acquire lock
    counter++;            // Critical section
    mtx.unlock();         // Release lock
}

RAII with std::lock_guard

Better approach using RAII:

cpp
#include <mutex>
#include <thread>

std::mutex mtx;
int counter = 0;

void increment_counter() {
    std::lock_guard<std::mutex> lock(mtx);  // Automatically locks
    counter++;                               // Critical section
    // Automatically unlocks when lock goes out of scope
}

Mutex Properties

  • Only one thread can hold the lock at a time.
  • If a thread tries to lock an already-locked mutex, it blocks (waits) until the mutex becomes available.

Common Mutex Patterns

1. Protecting a Single Variable

cpp
std::mutex counter_mutex;
int shared_counter = 0;

void safe_increment() {
    std::lock_guard<std::mutex> lock(counter_mutex);
    shared_counter++;
}

2. Protecting Multiple Variables

cpp
std::mutex data_mutex;
int x = 0, y = 0;

void update_both() {
    std::lock_guard<std::mutex> lock(data_mutex);
    x++;
    y++;
}

3. Fine-Grained Locking

cpp
std::mutex x_mutex, y_mutex;
int x = 0, y = 0;

void update_x() {
    std::lock_guard<std::mutex> lock(x_mutex);
    x++;
}

void update_y() {
    std::lock_guard<std::mutex> lock(y_mutex);
    y++;
}

Deadlocks

A deadlock occurs when two or more threads are waiting for each other to release locks.

Classic Deadlock Example

cpp
std::mutex mtx1, mtx2;

void thread_a() {
    mtx1.lock();     // Lock mtx1
    // ... some work
    mtx2.lock();     // Wait for mtx2 (held by thread_b)
    // ... critical section
    mtx2.unlock();
    mtx1.unlock();
}

void thread_b() {
    mtx2.lock();     // Lock mtx2
    // ... some work
    mtx1.lock();     // Wait for mtx1 (held by thread_a)
    // ... critical section
    mtx1.unlock();
    mtx2.unlock();
}

Preventing Deadlocks

  1. Always lock in the same order:
cpp
void safe_thread_a() {
    std::lock_guard<std::mutex> lock1(mtx1);
    std::lock_guard<std::mutex> lock2(mtx2);
}

void safe_thread_b() {
    std::lock_guard<std::mutex> lock1(mtx1);  // Same order!
    std::lock_guard<std::mutex> lock2(mtx2);
}
  1. Use std::lock() for multiple mutexes:
cpp
void safe_update() {
    std::lock(mtx1, mtx2);  // Locks both atomically
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
}

Performance Considerations

Lock Granularity

  • Coarse-grained: One lock for everything (simple, but less concurrency)
  • Fine-grained: Many small locks (complex, slow, but more concurrency)

Lock Contention

  • High contention: Many threads waiting for the same lock
  • Low contention: Few conflicts, good performance

Alternatives to Mutexes

  • std::atomic: For simple operations (increment, compare-exchange)
  • Lock-free data structures: Complex but very fast
  • Read-write locks: std::shared_mutex for read-heavy workloads

Best Practices

  1. Keep critical sections small: Minimize time spent holding locks
  2. Use RAII: Prefer std::lock_guard over manual lock/unlock
  3. Lock ordering: Always acquire locks in the same order
  4. Avoid nested locks: Use std::lock() for multiple mutexes
  5. Consider alternatives: Use std::atomic when possible
  6. Test thoroughly: Race conditions are timing-dependent and hard to reproduce

Implement a thread-safe bank account class with deposit and withdraw methods using std::mutex. The account should prevent overdrafts and maintain balance consistency across multiple threads.

cpp
// TODO: Implement thread-safe deposit method
    // Should add amount to balance and return true if successful
    bool deposit(double amount) {
        // USER CODE HERE
    }

    // TODO: Implement thread-safe withdraw method
    // Should subtract amount from balance if sufficient funds available
    // Return true if withdrawal successful, false if insufficient funds
    bool withdraw(double amount) {
        // USER CODE HERE
    }

    // TODO: Implement thread-safe get_balance method
    // Should return current balance
    double get_balance() const {
        // USER CODE HERE
    }
};

void deposit_worker(BankAccount& account, double amount, int times) {
    for (int i = 0; i < times; ++i) {
        account.deposit(amount);
    }
}

void withdraw_worker(BankAccount& account, double amount, int times) {
    for (int i = 0; i < times; ++i) {
        account.withdraw(amount);
    }
#include <mutex>
#include <thread>

class BankAccount {
private:
    double balance;
    std::mutex mtx;

public:
    BankAccount(double initial_balance = 0.0) : balance(initial_balance) {}

}