CppMutexes & Locks

Mutexes & Locks

The previous page ended with a warning: multiple threads touching the same data at once can go wrong in ways that are invisible from reading either thread's code alone. This page shows exactly how that happens — a race condition — and the standard tools C++ gives you to prevent it.
A broken example: two threads, one counter
Incrementing an integer, counter++, looks like a single operation, but it's actually three: read the current value, add one, write it back. If two threads interleave those three steps, one thread's update can be silently lost.

race-condition.cpp

CPP
#include <iostream>
#include <thread>

int counter = 0;

void increment() {
  for (int i = 0; i < 100000; ++i) {
    counter++; // NOT atomic: read, add, write — three separate steps
  }
}

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

  // Expected: 200000. Actual: often less, and different every run!
  std::cout << "Counter: " << counter << "\n";
  return 0;
}
Both threads can read the same value of counter before either writes its update back, so one of the two increments gets overwritten and lost. Run this program several times and you'll likely see a different (wrong) number each time — a hallmark of a race condition.
std::mutex — mutual exclusion
A std::mutex ("mutual exclusion") lets only one thread hold it at a time. Calling .lock() waits until the mutex is available and then claims it; any other thread calling .lock() blocks until it's released with .unlock().

manual-mutex.cpp

CPP
#include <iostream>
#include <mutex>
#include <thread>

int counter = 0;
std::mutex counterMutex;

void increment() {
  for (int i = 0; i < 100000; ++i) {
    counterMutex.lock();
    counter++;
    counterMutex.unlock();
  }
}

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

  std::cout << "Counter: " << counter << "\n"; // always 200000 now
  return 0;
}
Prefer RAII locks over manual lock/unlock
Manual unlock() is easy to forget
If an exception is thrown between .lock() and .unlock(), or an early return skips past the unlock call, the mutex stays locked forever — every other thread waiting on it blocks permanently (a deadlock). Just like resource cleanup in general, this is a job for RAII: std::lock_guard locks a mutex in its constructor and unlocks it in its destructor, guaranteeing the unlock happens no matter how the scope is exited.

lock-guard.cpp

CPP
#include <mutex>

int counter = 0;
std::mutex counterMutex;

void increment() {
  for (int i = 0; i < 100000; ++i) {
    std::lock_guard<std::mutex> guard(counterMutex);
    counter++;
    // guard's destructor unlocks counterMutex here automatically,
    // even if an exception is thrown on the line above.
  }
}
std::unique_lock is a more flexible RAII lock — it supports unlocking and re-locking mid-scope, deferred locking, and works with condition variables — at a small extra cost compared to lock_guard. Reach for lock_guard by default, and unique_lock only when you need that extra flexibility.
Deadlock: two mutexes, opposite order

A deadlock happens when two threads each hold a lock the other needs, and neither can proceed. The classic case: two mutexes locked in different orders by different threads.

deadlock.cpp

CPP
#include <mutex>
#include <thread>

std::mutex mA, mB;

void threadOne() {
  std::lock_guard<std::mutex> lockA(mA);
  std::lock_guard<std::mutex> lockB(mB); // waits for mB
}

void threadTwo() {
  std::lock_guard<std::mutex> lockB(mB);
  std::lock_guard<std::mutex> lockA(mA); // waits for mA — deadlock!
}
If threadOne grabs mA right as threadTwo grabs mB, each thread is now waiting forever for a mutex the other one holds.
The fix: lock multiple mutexes together
std::lock() locks several mutexes at once using a deadlock-avoidance algorithm, regardless of the order you pass them in. C++17's std::scoped_lock combines that with RAII, making it the simplest safe way to lock more than one mutex.

scoped-lock.cpp

CPP
#include <mutex>

std::mutex mA, mB;

void safeThreadOne() {
  std::scoped_lock lock(mA, mB); // locks both, deadlock-free, in any order
}

void safeThreadTwo() {
  std::scoped_lock lock(mB, mA); // safe even though the order is reversed
}
Note
Reach for std::lock_guard (single mutex) or std::scoped_lock (one or more mutexes) by default. Both are RAII wrappers, so locking and unlocking follow the same automatic-cleanup guarantee described on the RAII page.
  • A race condition happens when unsynchronized threads read/write shared data at the same time.

  • std::mutex provides mutual exclusion via .lock() / .unlock().

  • Prefer std::lock_guard / std::unique_lock — RAII guarantees the unlock happens even on exceptions or early returns.

  • Deadlock occurs when threads lock multiple mutexes in inconsistent orders.

  • std::scoped_lock (C++17) locks multiple mutexes together, deadlock-free, and unlocks them via RAII.