Thread Scheduling Basics
A deadlock occurs when threads each hold one resource and wait for a resource held by another thread, so none of them can proceed.
A Stall Nobody Caused
Many useful actions cannot be finished with a single shared resource. A program may need to update one data structure and then another, and each update must be protected so that no other thread changes the data in the middle of the operation. Locks, implemented as mutexes, provide that protection. The trouble begins when a task needs more than one lock at the same time. The way those locks are requested can turn a correct program into one that stops making progress entirely.
Think of two people each holding one half of a pair of scissors and waiting for the other person to give up the second half. Neither person did anything wrong on their own, and neither will finish cutting. The stall comes from the arrangement of who holds what and who is waiting for what. This article looks at that arrangement in threads and mutexes, and at the tools the standard library offers to avoid it.
Two Threads, Two Mutexes
Consider two threads and two mutexes, called mutex1 and mutex2. Suppose thread1 acquires mutex1 and then tries to acquire mutex2. At the same moment, thread2 acquires mutex2 and then tries to acquire mutex1. Each thread now holds one mutex and is waiting for the mutex the other one holds. Neither can move forward, because the lock it needs is locked by the other thread, and that other thread will not release it while it is itself stuck.
Tracing the interleaving step by step
thread1 and thread2 run at the same time. thread1 locks mutex1 then wants mutex2. thread2 locks mutex2 then wants mutex1. What does each mutex and thread look like after each step?
- 1Step 1
thread1 acquires mutex1. Owner of mutex1 is thread1. At the same moment thread2 acquires mutex2. Owner of mutex2 is thread2.
- 2Step 2
thread1 tries to acquire mutex2. mutex2 is held by thread2, so thread1 waits. It still holds mutex1.
- 3Step 3
thread2 tries to acquire mutex1. mutex1 is held by thread1, so thread2 waits. It still holds mutex2.
- 4Step 4
Each thread is waiting for a mutex that the other thread will not release while it is itself stuck. No thread can take the next step.
Each thread holds one mutex and waits for the other. Neither can proceed.
The diagram below shows the same arrangement as a loop of ownership and waiting. Each mutex points back to the thread that holds it, and each thread points to the mutex it is trying to get. Following the arrows around the loop shows why no one can move first.
Notice that each arrow in the loop describes a true fact at the same time. mutex1 is held by thread1, thread1 is waiting for mutex2, mutex2 is held by thread2, and thread2 is waiting for mutex1. Because the dependency closes into a circle, there is no starting point from which one thread can finish first.
Why the Stall Never Ends
Releasing a lock is something a thread does when it is done with the data. In the scenario above, each thread would have to finish its work to release its lock, but finishing its work requires the lock it is waiting for. Each thread's release depends on progress that requires the very lock it is waiting for, so the stall is permanent.
Because the dependency runs in a circle, no step can start the chain moving again. Waiting is not the problem by itself. Waiting becomes a deadlock when the waiting forms a loop in which every party is blocked by another party in the loop.
How lock() Acquires Every Mutex at Once
The standard library offers an operation for this situation: lock(). Instead of grabbing one mutex and then asking for the next, a thread hands all of the mutexes it needs to lock() as arguments. The operation is designed around two guarantees. First, lock() proceeds only after it has acquired all of its mutex arguments. Second, it never blocks, meaning it never goes to sleep, while it is holding a mutex.
lock() acquires all of its mutex arguments together, proceeds only after getting every one, and never blocks while holding a mutex.
These two guarantees break the circle that caused the deadlock. In the earlier scenario, thread1 was holding mutex1 while sleeping on mutex2. With lock(), a thread does not sit on a partial set of mutexes while waiting for the rest. It either obtains everything it asked for and continues, or it does not hold any of them while it waits. The sequence below shows the order of events when a thread calls lock() with two mutexes.
Tracing Two Threads That Use lock()
Same two mutexes, requested through lock()
thread1 and thread2 both need mutex1 and mutex2. Each calls lock() with both mutexes as arguments. What prevents the circular wait from forming?
- 1Step 1
thread1 calls
lock(mutex1, mutex2). The operation does not take mutex1 and then wait for mutex2 as two separate decisions. It works toward acquiring both. - 2Step 2
thread2 calls
lock(mutex2, mutex1)at the same time. It also works toward acquiring both. - 3Step 3
Under the guarantees described, a thread that cannot yet get every mutex does not sit on a partial set while it waits. It does not hold any mutex while it waits, so no thread is left holding the one lock the other needs.
- 4Step 4
Once one thread has obtained every mutex it asked for, it proceeds. When it finishes and releases them, the other thread can get what it needs.
The circle is broken because no thread is ever stuck holding one mutex while waiting for another.
Releasing Mutexes with unique_lock
Acquiring locks is only half of the job. They also have to be released, or other threads will wait forever. The source material describes unique_lock as the tool that handles this. Each unique_lock object wraps a mutex, and its destructor releases that mutex when the object goes out of scope. When a thread leaves the block of code where the unique_lock was declared, the mutex it guards is released without the programmer writing an explicit unlock.
The source ties this to lock() as well. The destructors of the individual unique_lock objects ensure that the mutexes are released when a thread leaves the scope. A thread that has acquired its mutexes through lock() and wrapped each one in a unique_lock can rely on scope exit to free them, including the case where the thread leaves that scope early.
The diagram shows that the mutex moves from unlocked to locked when the unique_lock takes ownership, and back to unlocked when the destructor runs. The arrow labeled scope exit covers both the normal end of the block and an early departure from it. The release is tied to scope rather than to an explicit call, so the programmer does not have to remember to unlock on every possible path.
Mistakes Beginners Make with Locks
Common mistake Blaming one thread for a stall that involves two
What it looks likeLooking at thread1 alone and asking why it is stuck, when thread1 is only waiting for a mutex that thread2 holds.
Why it failsThe stall comes from the arrangement of who holds what and who is waiting for what. Neither thread need have done anything wrong on its own.
Do this insteadMap the full chain of ownership and waiting before deciding where the fault lies. Look for the loop.
Common mistake Assuming a thread can safely hold one mutex while requesting another one separately
What it looks likeLocking mutex1, then asking for mutex2 in a later statement, while another thread does the reverse order.
Why it failsThis is the pattern that produces the circular wait. Each thread holds one mutex while sleeping on the other.
Do this insteadRequest the full set of mutexes together through lock(), so the thread does not sit on a partial set while it waits.
Common mistake Thinking lock() holds a partial set while it waits for the rest
Why it failsThe source states that
lock()proceeds only after acquiring all of its mutex arguments and never blocks while holding a mutex.Do this insteadRemember the two guarantees: all or nothing for acquisition, and no sleeping while holding a mutex.
Common mistake Expecting to call an explicit unlock on every exit path
What it looks likeAdding unlock calls before each return or early exit inside a block that uses unique_lock.
Why it failsThe source says each
unique_lockreleases its mutex through its destructor when the thread leaves the scope.Do this insteadWrap each mutex in a unique_lock declared in the block, and let scope exit release it.
Predict the Outcome Before Reading On
What do you think happens?
thread1 has locked mutex1 and is now waiting for mutex2. thread2 has locked mutex2 and is now waiting for mutex1. What happens next?
Reveal answer
Neither thread can proceed. Each holds one mutex and waits for the one the other holds, so the stall is permanent.
Each thread would need to release its mutex to let the other continue, but releasing requires finishing work that depends on the lock it is waiting for. The dependency is a circle, so nothing can start it moving again.
Two threads each need mutex A and mutex B. Thread X locks A, then tries to lock B. Thread Y locks B, then tries to lock A. Draw the ownership and waiting loop. Then rewrite the requests so that both threads use lock() with both mutexes as arguments, and explain in one or two sentences why the loop cannot form.
Hints
- Start by listing who holds each mutex after each step.
- Ask whether any thread ever holds one mutex while sleeping on the other after switching to
lock().
Key Takeaways
- A deadlock occurs when threads each hold one resource and wait for a resource held by another thread, so none of them can proceed.
- The stall is permanent because each thread's release depends on progress that requires the lock it is waiting for, and the dependency forms a circle.
lock()acquires all of its mutex arguments together, proceeds only after getting every one, and never blocks while holding a mutex, which breaks the circular wait.unique_lockreleases its mutex through its destructor when the thread leaves the scope, so release is tied to scope rather than to an explicit unlock call.