Concepts / Mutex Basics and Critical Sections

Mutex Basics and Critical Sections

lock() will never block while holding a mutex. The call is designed so that it does not sit waiting on one mutex while it already keeps another one locked.

  • Programming

Two Pieces of Shared Data

Imagine a program in which one thread must update two separate pieces of shared data. Each piece is protected by its own mutex. For the duration of the update, the thread now holds, or needs to hold, two locks at the same moment. Taking one lock and then the other sounds simple, but the way the locks are acquired, and what the thread does while it waits for them, determines whether the program stays safe.

This article looks at one specific tool for this job: a single lock() call that receives several mutexes, working together with unique_lock objects. The guiding question is a simple one. When a thread asks for several mutexes at once, what does the program actually guarantee?

Tracing a Call That Takes Several Locks

Before reading the rules, follow the path a thread takes. The diagram below starts at the moment a thread calls lock() with several mutexes and ends when those mutexes are returned to the system. Read it from the top. The thread cannot move past the acquisition step until every mutex argument has been acquired, and the release happens at the end of the scope rather than at an explicit unlock line.

Call lock(m1, m2)All mutexes acquired?Hold back, keepwaitingCode after the callrunsThread leaves scopeunique_lockdestructors releasemutexesacquirenoretryyeswork finishesdestructors run
What happens step by step when lock(m1, m2) runs, and where does the thread hold back or proceed?

The flowchart shows the order of events, not the internal mechanics of how acquisition is carried out, and it does not depend on any particular implementation. What it captures is the structure the source describes: an acquisition step that completes fully before the work begins, followed by a cleanup step tied to the scope.

What do you think happens?

A thread calls lock(m1, m2). It has just acquired m1 but not yet m2. Does the code after the call run yet?

  • Yes, because m1 is already held

  • No, the code runs only after all mutexes passed to the call are held

Reveal answer
Answer:

No, the code runs only after all mutexes passed to the call are held.

The source says a call to lock() with several mutex arguments proceeds only after it has acquired all of them. The call does not return control as soon as one mutex is grabbed, so the thread never reaches its critical work with a partial set of locks.

What lock() Promises About Its Mutexes

Definition

A call to lock() with several mutex arguments proceeds only after it has acquired all of them. The phrase proceeds only after is the heart of the idea: the thread holds back until every mutex passed to the call is in hand, and only then does the code after the call run.

Key point

lock() will never block while holding a mutex. The call is designed so that it does not sit waiting on one mutex while it already keeps another one locked.

These two promises work together. The first guarantees completeness: the thread never proceeds with a partial set of locks. The second guarantees that the waiting itself is handled safely, so a thread is not left holding one mutex while stuck behind another. Together they make a multi-lock acquisition safer than a sequence of ordinary single-lock calls, where each call waits in turn while the earlier locks remain held.

Why All-or-Nothing Beats One-at-a-Time

Consider the two approaches side by side. In the naive approach, a thread takes one mutex, then calls for the second as a separate step. In the single-call approach, the thread asks for both mutexes in one lock() call. The diagram compares the state of the mutexes before the call and after it returns, for the single-call approach.

m1 not heldm1 heldm2 not heldm2 heldlock(m1, m2) returnslock(m1, m2) returns
What is the state of each mutex before and after lock() returns?

When lock() returns, both mutexes are held, and the code after the call is the first point at which the thread is working with both. In the naive approach, there is an intermediate moment in which m1 is held while the thread is still waiting for m2. The single call is designed so that this intermediate waiting-while-holding state is not what the call does: the call does not wait on one mutex while keeping another locked.

The next diagram shows why the one-at-a-time pattern is risky. It uses two threads that take the same two mutexes in opposite orders. This illustration is a generated teaching example that follows the reasoning above, not a scenario described in the source.

Thread AThread Bmutex m1mutex m21. acquire m12. acquire m23. wait for m2 (held by B)4. wait for m1 (held by A)
How do two threads interleave so that each ends up waiting on a lock the other holds?

Each thread holds one mutex and waits for the other. Neither can proceed, because each is waiting on a lock the other will not release until it finishes. A single lock() call that acquires both mutexes at once removes the intermediate state in which one thread holds a lock while waiting for another, which is exactly the kind of waiting that this sequence illustrates.

Releasing Mutexes at Scope Exit

Acquiring the locks is only half of the story. The other half is making sure they are given back. The source attributes this job to the destructors of the individual unique_lock objects. A destructor is the cleanup routine that runs automatically when an object's lifetime ends. Because each mutex is wrapped in its own unique_lock, the release of each mutex is tied to the lifetime of that wrapper.

Holds its mutexThread leaves scopeDestructor runsMutex releasedscope endsobject lifetime endscleanup
How does a unique_lock move from locked to unlocked, and what triggers that change?

The practical consequence is that the programmer does not need to remember a separate release step for every mutex. When a thread leaves the scope in which its unique_lock objects were created, their destructors run and the mutexes they guard are released. The scope, not a manual instruction, controls the moment of release.

The Critical Section and Its Boundaries

The region of code that is protected while the mutexes are held is the place where the thread does its critical work on the shared data. The source's guarantee covers acquisition and non-blocking behavior. It does not cover every action a thread takes while it holds the locks. That means safe multi-lock code also depends on keeping the held region focused: the longer and broader the work done while holding the locks, the more the program depends on what that work does.

Mistakes That Undermine Multi-Lock Safety

  • Common mistake

    Taking the mutexes one at a time when they could be acquired together

    What it looks like

    Locking m1 in one statement, then locking m2 in a separate statement, while the thread keeps m1 held during the second wait.

    Why it fails

    The source's guarantee applies to a single lock() call that receives several mutexes. Splitting the acquisition creates the intermediate state of holding one lock while waiting for another, which is what the single call is meant to avoid.

    Do this instead

    Pass all the mutexes needed together to one lock() call so the thread proceeds only once every one is held.

  • Common mistake

    Assuming code runs after the first mutex is acquired

    What it looks like

    Starting the critical work as soon as m1 is in hand, before the rest of the call has finished.

    Why it fails

    A call to lock() proceeds only after it has acquired all of the mutexes it was given. Work started early runs with a partial set of locks.

    Do this instead

    Place the critical work after the lock() call returns, when every mutex is held.

  • Common mistake

    Expecting a manual release step for every mutex

    What it looks like

    Writing code that assumes each mutex must be unlocked by an explicit statement at the end of the block.

    Why it fails

    The source ties release to the destructors of the individual unique_lock objects, so release happens when the scope is left.

    Do this instead

    Create the unique_lock objects in the scope that needs the mutexes and let their destructors release them when the thread leaves that scope.

  • Common mistake

    Holding the locks while doing unrelated work

    What it looks like

    Keeping mutexes locked during slow or unrelated operations that never touch the shared data.

    Why it fails

    The guarantee covers acquisition and non-blocking behavior, not every action performed while holding the locks. Extra work inside the held region widens the period during which the mutexes are unavailable to other threads.

    Do this instead

    Keep the held region focused on the operations that actually use the protected data.

  • Common mistake

    Acquiring mutexes in different orders across threads, which can leave each thread waiting on a lock held by the other

Practice: Tracing Your Own Acquisition

MEDIUM

A thread must update two pieces of shared data, each guarded by its own mutex, m1 and m2. Describe, step by step, the order in which the thread acquires the mutexes, where the thread is allowed to start its critical work, and when the mutexes are released. Then explain why acquiring both in a single lock() call is safer than taking m1 and then waiting for m2 in a separate step.

Hints
  • Ask yourself at which point the code after the lock() call is allowed to run.
  • Think about what the thread holds while it waits, in each approach.
  • Identify which object's destructor releases each mutex and what triggers it.

Key Takeaways

  • A call to lock() with several mutex arguments proceeds only after it has acquired all of them, so the thread never works with a partial set of locks.
  • lock() will never block while holding a mutex, which makes a single multi-lock call safer than a chain of separate single-lock waits.
  • The destructors of the individual unique_lock objects release the mutexes when the thread leaves scope, so release is tied to scope rather than to a manual step.
  • Keep the held region focused, because the guarantee covers acquisition and non-blocking behavior, not every action taken while holding the locks.

Related concepts

More concepts starting with M