Shared State, Race Conditions and Mutex Locks
Concurrency and Multithreading

7.2 Shared State, Race Conditions and Mutex Locks

Spawning threads is simple, but managing shared memory is where concurrency becomes dangerous. If two or more threads attempt to read and write to the same memory variable simultaneously, you trigger a race condition. This leads to corrupted variables and undefined behavior.

Let's look at why race conditions happen on hardware registers and how to secure them using Mutex locks.

The Anatomy of a Race Condition

At the C++ code level, incrementing a variable looks like one operation: count++. At the CPU level, however, it is three separate operations:

1. Read the variable value from RAM into a CPU register.

2. Increment the value inside the CPU register.

3. Write the updated value back from the register to RAM.

If Thread A and Thread B try to increment count simultaneously, their operations can interleave. Thread A reads count (say it is 10), then Thread B reads count (still 10). Both increment to 11, and both write 11 back. You have executed two increments, but the count only increased by one. Your data is corrupted.

Mutexes: Mutual Exclusion

To prevent race conditions, we use a Mutex (mutual exclusion) object from the <mutex> header. A mutex acts as a lock. A thread must acquire the lock before entering a critical section of code, and release it when finished. Only one thread can hold the lock at a time.

* The Bathroom Key Metaphor: A mutex is like a single physical bathroom key in a coffee shop. If you want to use the bathroom (the shared resource), you must acquire the key (lock the mutex). If another customer wants to use the bathroom, they must wait in a queue until you exit and return the key (unlock the mutex).

RAII Locks: `std::lock_guard`

Manually locking and unlocking a mutex is dangerous. If your function throws an exception or returns early before you call unlock(), the mutex remains locked forever, freezing all other threads (a freeze known as deadlock).

To prevent this, C++ uses RAII locks like std::lock_guard. It locks the mutex in its constructor and automatically unlocks it in its destructor when the guard object exits scope:

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

int counter{0};
std::mutex counterMutex;

void incrementCounter() {
    for (int i{0}; i < 1000; ++i) {
        // RAII Lock: Locks the mutex here
        std::lock_guard<std::mutex> lock{counterMutex};
        ++counter;
    } // lock exits scope here, automatically unlocking the mutex!
}

int main() {
    std::thread t1{incrementCounter};
    std::thread t2{incrementCounter};
    
    t1.join();
    t2.join();
    
    std::cout << "Final Counter: " << counter << '\n'; // Guaranteed to be 2000!
    return 0;
}
Securing shared counters using std::lock_guard.

Thread Stack Isolation (When Not to Lock)

A common performance mistake is locking every variable inside a thread function. You only need locks for variables that reside in shared memory zones (like global variables, heap variables, or static variables) that multiple threads can access.

Any variable declared locally inside a thread function is allocated on that specific thread's own private stack frame. Since other threads cannot access this stack frame, local stack variables are inherently thread safe and require zero mutex locks. Keep your lock scopes as narrow as possible to maintain performance.

Finished reading this lesson?