Concepts / Thread Scheduling Basics

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.

  • Programming

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.

Worked example

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?

  1. 1
    Step 1

    thread1 acquires mutex1. Owner of mutex1 is thread1. At the same moment thread2 acquires mutex2. Owner of mutex2 is thread2.

  2. 2
    Step 2

    thread1 tries to acquire mutex2. mutex2 is held by thread2, so thread1 waits. It still holds mutex1.

  3. 3
    Step 3

    thread2 tries to acquire mutex1. mutex1 is held by thread1, so thread2 waits. It still holds mutex2.

  4. 4
    Step 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.

Result

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.

mutex1held by thread1thread1waiting for mutex2mutex2held by thread2thread2waiting for mutex1held bywaiting forheld bywaiting for
How do thread1 and thread2 end up waiting on each other, each holding the lock the other needs, so that neither can ever proceed?

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.

Key point

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.

thread1calls locklock()all or nothingmutex1acquiredmutex2acquiredlock(mutex1, mutex2)acquireacquireproceed with both
What is the order of events when lock(mutex1, mutex2) runs, and why does acquiring both as one step prevent a thread from holding one mutex while waiting for the other?

Tracing Two Threads That Use lock()

Worked example

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?

  1. 1
    Step 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.

  2. 2
    Step 2

    thread2 calls lock(mutex2, mutex1) at the same time. It also works toward acquiring both.

  3. 3
    Step 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.

  4. 4
    Step 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.

Result

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.

Unlockedno ownerLockedguarded by unique_lockUnlockedreleased by destructorunique_lock createdscope exit or early leave
What state does the mutex move through when a unique_lock is created, and what happens to it when the block ends, even if the code exits 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 like

    Looking at thread1 alone and asking why it is stuck, when thread1 is only waiting for a mutex that thread2 holds.

    Why it fails

    The 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 instead

    Map 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 like

    Locking mutex1, then asking for mutex2 in a later statement, while another thread does the reverse order.

    Why it fails

    This is the pattern that produces the circular wait. Each thread holds one mutex while sleeping on the other.

    Do this instead

    Request 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 fails

    The source states that lock() proceeds only after acquiring all of its mutex arguments and never blocks while holding a mutex.

    Do this instead

    Remember 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 like

    Adding unlock calls before each return or early exit inside a block that uses unique_lock.

    Why it fails

    The source says each unique_lock releases its mutex through its destructor when the thread leaves the scope.

    Do this instead

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

MEDIUM

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_lock releases its mutex through its destructor when the thread leaves the scope, so release is tied to scope rather than to an explicit unlock call.

More concepts starting with T