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:
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:
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.