Appearance
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
- Non-atomic operations: Reading, modifying, and writing isn't atomic
- Interleaved execution: Threads can switch between any instruction
- Shared state: Multiple threads accessing the same data
- 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
- 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);
}- 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
- Keep critical sections small: Minimize time spent holding locks
- Use RAII: Prefer std::lock_guard over manual lock/unlock
- Lock ordering: Always acquire locks in the same order
- Avoid nested locks: Use std::lock() for multiple mutexes
- Consider alternatives: Use std::atomic when possible
- 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) {}
}