Shared State, Race Conditions and Mutex Locks
Advanced Concurrency, SIMD, and Performance

9.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.
  1. Increment the value inside the CPU register.
  1. 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 Kernel Ring 0 Trap

An uncontended mutex is often cheap, but a contended mutex (one another thread already holds) may block and involve the operating system scheduler.

Your application runs in user space (Ring 3). When a thread blocks on a mutex, the kernel may deschedule it. That transition flushes speculative pipeline work, disturbs cache locality, and adds latency. This is why high-throughput code avoids mutexes in tight inner loops when a narrower synchronization strategy fits.

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?