Deadlocks and Condition Variables
Advanced Concurrency, SIMD, and Performance

9.3 Deadlocks and Condition Variables

While locks solve race conditions, they introduce new hazards. If your threads need to acquire multiple locks to perform a task, they can freeze each other in a standoff called a deadlock. We also need ways to let threads coordinate without consuming 100 percent CPU in loops.

Let's look at deadlock avoidance, and thread coordination using condition variables.

Deadlocks: The Polite Standoff

A deadlock occurs when two threads are blocked forever, each waiting for a lock held by the other. For example:

  • Thread A locks Mutex A, then tries to lock Mutex B.
  • Thread B locks Mutex B, then tries to lock Mutex A.

Both threads wait forever. Neither can release their current lock because they are waiting to acquire the second one.

  • The Doorway Metaphor: A deadlock is like two extremely polite people meeting at a narrow doorway. Both step aside to let the other pass, and both refuse to walk through until the other goes first. They stand there frozen face to face forever.

Avoiding Deadlocks with `std::scoped_lock`

To avoid lock acquisition cycles, C++17 introduced std::scoped_lock. It accepts multiple mutexes and locks them all simultaneously using a deadlock avoidance algorithm, ensuring they are always acquired safely:

#include <mutex>

std::mutex mutexA;
std::mutex mutexB;

void safeTransfer() {
    // Locks both mutexes simultaneously without deadlocking!
    std::scoped_lock lock{mutexA, mutexB};
    // Perform transfer operations...
}
Locking multiple mutexes safely using std::scoped_lock.

Condition Variables: Event Signaling

Often, a thread needs to wait for an event (such as a database query to complete or a queue to receive data). If the thread continuously loops checking a variable, it wastes CPU cycles. This is called busy waiting.

Instead, we use a std::condition_variable (from the <condition_variable> header). It allows a thread to sleep until it is explicitly signaled by another thread:

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready{false};

void waitForWork() {
    std::unique_lock<std::mutex> lock{mtx};
    
    // Sleep until ready becomes true. Releases the lock while sleeping!
    cv.wait(lock, []{ return ready; });
    
    std::cout << "Worker thread: Signal received! Performing task...\n";
}

int main() {
    std::thread worker{waitForWork};
    
    // Simulate preparing work
    std::this_thread::sleep_for(std::chrono::seconds(2));
    
    {
        std::lock_guard<std::mutex> lock{mtx};
        ready = true;
    }
    cv.notify_one(); // Wake up the sleeping worker thread!
    
    worker.join();
    return 0;
}
Safe thread signaling with condition variables.

The Spurious Wakeup Anomaly

Notice that cv.wait(lock, []{ return ready; }) takes a lambda predicate condition. Why?

Because operating systems are noisy. The OS can randomly wake up sleeping threads due to hardware interrupts, network signals, or scheduler quirks, even if no other thread called notify_one(). This is called a Spurious Wakeup.

If you did not have a while loop checking the ready condition, the thread could wake up randomly, assume the data is ready, and crash the system by reading garbage. The lambda predicate forces the thread to go back to sleep if the wakeup was a false alarm.

Finished reading this lesson?