C++ scoped_lock — Scoped locking, std::lock, and deadlock
Key takeaways
Two threads locking the same pair of mutexes in opposite order is the classic deadlock. std::scoped_lock uses std::lock to acquire them all safely, but it is not movable and cannot unlock early. The post covers when unique_lock is still needed, what locking actually costs, and patterns for safe swaps, multi-resource updates and layered locks.
What is scoped_lock?
std::scoped_lock (C++17) is an RAII lock that locks multiple mutexes at once and automatically unlocks all of them when the scope ends. It is the multi-mutex counterpart of lock_guard and acquires all of its mutexes with a deadlock-avoidance algorithm, so the order in which you list them does not matter. This article is only about acquiring several locks together; std::mutex itself, unique_lock, recursive_mutex and timed_mutex are introduced in C++ Mutex and lock_guard. Reading thread basics and data races and mutexes first is recommended.
The problem it solves appears whenever one operation needs two locks. Thread A locks m1 and then waits for m2; meanwhile thread B has locked m2 and waits for m1. Neither can proceed, and neither will ever release what it holds. The program does not crash — it just stops, usually under load and rarely in tests, which is what makes deadlocks expensive to find. The classic fix is a global lock order (“always lock the lower address first”), but that relies on every programmer following the rule in every code path. scoped_lock removes the need for the rule for the mutexes it acquires together.
#include <mutex>
std::mutex m1, m2;
void func() {
std::scoped_lock lock(m1, m2); // deadlock avoidance
// ...
}
Why it matters:
- Deadlock avoidance: safely locks multiple mutexes
- RAII: automatic unlock for exception safety
- Simplicity: replaces more verbose C++11 patterns
- Generality: supports both single and multiple mutexes
// ❌ Manual locking: deadlock risk
std::mutex m1, m2;
void transfer1() {
m1.lock();
m2.lock(); // can deadlock!
// ...
m2.unlock();
m1.unlock();
}
void transfer2() {
m2.lock();
m1.lock(); // can deadlock!
// ...
m1.unlock();
m2.unlock();
}
// ✅ scoped_lock: deadlock avoidance
void transfer1() {
std::scoped_lock lock(m1, m2); // safe
// ...
}
void transfer2() {
std::scoped_lock lock(m2, m1); // safe (order does not matter)
// ...
}
How scoped_lock works
Internally, scoped_lock uses std::lock() and a deadlock-avoidance algorithm to lock all mutexes. The destructor unlocks every mutex automatically.
// Conceptual implementation
template<typename... Mutexes>
class scoped_lock {
std::tuple<Mutexes&...> mutexes_;
public:
scoped_lock(Mutexes&... mutexes) : mutexes_(mutexes...) {
std::lock(mutexes...); // deadlock-avoidance algorithm
}
~scoped_lock() {
// unlock in reverse order
unlock_all(mutexes_);
}
// not copyable or movable
scoped_lock(const scoped_lock&) = delete;
scoped_lock& operator=(const scoped_lock&) = delete;
};
Deadlock-avoidance algorithm
std::lock() uses a try-lock-based algorithm to avoid deadlock:
- Lock the first mutex.
- Try to lock the rest with
try_lock(). - On failure, unlock all and retry.
- Repeat until all locks succeed.
// Conceptual behavior of std::lock
void lock(Mutex& m1, Mutex& m2) {
while (true) {
m1.lock();
if (m2.try_lock()) {
return; // success
}
m1.unlock(); // failed, retry
std::this_thread::yield();
}
}
The standard does not specify the exact algorithm, only that std::lock must not deadlock. Real implementations improve on the sketch above: libstdc++ and libc++, for example, remember which mutex could not be acquired and start with a blocking lock() on that one in the next round, so the thread waits on the contended mutex instead of spinning. The practical consequence is that every mutex passed to scoped_lock with two or more arguments must support try_lock() — plain std::mutex, std::recursive_mutex, std::timed_mutex and std::shared_mutex all do. Under heavy contention the back-off can repeat a few times, but it cannot get stuck in a cycle the way two fixed-order acquisitions can.
Locking one or several mutexes
std::mutex mtx;
void func() {
std::scoped_lock lock(mtx);
// automatic lock/unlock
}
With one argument, scoped_lock just calls mtx.lock() and behaves exactly like lock_guard. The template arguments are deduced from the constructor arguments (C++17 class template argument deduction), so you do not write std::scoped_lock<std::mutex>. That convenience has a sharp edge: because scoped_lock accepts zero mutexes, a declaration that forgets the argument still compiles. std::scoped_lock lock; locks nothing, and the even sneakier std::scoped_lock(mtx); declares a new, empty scoped_lock variable named mtx that shadows the real mutex — the code compiles, runs, and protects nothing. std::lock_guard<std::mutex> lk; would have been a compile error. Always give the lock object a name and pass the mutex, and consider enabling compiler warnings for shadowing (-Wshadow) to catch the second form.
Transfers, multi-resource updates, and conditional locking
A bank transfer between two accounts
class Account {
mutable std::mutex mtx; // mutable: locked in const getBalance()
int balance;
public:
Account(int b) : balance(b) {}
friend void transfer(Account& from, Account& to, int amount) {
if (&from == &to) return; // locking the same mutex twice is undefined behavior
// deadlock avoidance
std::scoped_lock lock(from.mtx, to.mtx);
from.balance -= amount;
to.balance += amount;
}
int getBalance() const {
std::scoped_lock lock(mtx);
return balance;
}
};
This is the textbook case for scoped_lock: transfer(a, b) and transfer(b, a) running concurrently would deadlock with naive locking, because each call locks its own from first. Two details in the code are easy to miss. The self-transfer check matters because scoped_lock(m, m) passes the same non-recursive mutex twice; std::lock would try to lock it while already holding it, which is undefined behavior and in practice hangs. And the mutex must be mutable, otherwise getBalance() const fails to compile — locking modifies the mutex, and in a const member function the mutex is const. Note also what the lock does not cover: reading a.getBalance() + b.getBalance() from another thread takes the two locks one at a time, so it can observe the money “in flight” between the two calls. Consistent totals need both locks held for the whole read.
Updating several resources together
std::mutex m1, m2, m3;
int data1, data2, data3;
void update() {
std::scoped_lock lock(m1, m2, m3);
data1++;
data2++;
data3++;
}
A single mutex
std::mutex mtx;
void func() {
std::scoped_lock lock(mtx); // same idea as lock_guard
// ...
}
Conditional locking
std::mutex m1, m2;
void func(bool needBoth) {
if (needBoth) {
std::scoped_lock lock(m1, m2);
// ...
} else {
std::scoped_lock lock(m1);
// ...
}
}
Each branch has its own lock object because scoped_lock<std::mutex, std::mutex> and scoped_lock<std::mutex> are different types, and the lock cannot be declared first and filled in later. If the set of mutexes is only known at run time — say, a list of account IDs — scoped_lock cannot help; you lock them in a fixed global order (sort by ID or address) or build unique_locks with std::defer_lock and pass them to std::lock, which works for a fixed number of arguments only.
lock_guard vs scoped_lock
// lock_guard: single mutex
std::lock_guard<std::mutex> lock(mtx);
// scoped_lock: multiple mutexes
std::scoped_lock lock(m1, m2, m3);
// scoped_lock is more general
Deadlock, ordering, exceptions, and movability
Deadlock from inconsistent lock order
// ❌ manual locking (can deadlock)
m1.lock();
m2.lock();
// ✅ scoped_lock
std::scoped_lock lock(m1, m2);
Ordering no longer matters
// Thread 1
std::scoped_lock lock(m1, m2);
// Thread 2
std::scoped_lock lock(m2, m1); // OK: deadlock avoided
Unlocking on exceptions
std::scoped_lock lock(m1, m2);
process(); // unlocks automatically even if an exception is thrown
scoped_lock cannot be moved
std::scoped_lock lock(mtx);
// ❌ cannot move
// auto lock2 = std::move(lock);
// use unique_lock when you need move semantics
std::unique_lock<std::mutex> ulock(mtx);
auto ulock2 = std::move(ulock); // OK
Not being movable is a deliberate design choice, not a missing feature. A scoped_lock always owns its mutexes from construction to destruction, which lets it store only references and skip the “do I currently own the lock?” flag that unique_lock carries. It also means you cannot return one from a function to hand the lock to a caller, put it in a container, or release it early — all of which are unique_lock jobs. If you only need to end the critical section early, an inner block { std::scoped_lock lk(m); ... } around the protected code is simpler than switching types.
Getting the same effect in C++11
// C++11: std::lock + lock_guard
std::mutex m1, m2;
void func() {
std::lock(m1, m2);
std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
}
// C++17: scoped_lock (simpler)
void func() {
std::scoped_lock lock(m1, m2);
}
In the C++11 version, std::adopt_lock tells each lock_guard that the mutex is already locked, so it only takes over the unlocking. The order matters: if an exception were thrown between std::lock and the guards’ construction, the mutexes would stay locked forever; here nothing can throw in between, but it is the kind of invariant that breaks when someone later inserts a line. Forgetting adopt_lock is worse — the guard calls lock() a second time on a mutex the thread already holds, which is undefined behavior and usually a self-deadlock. scoped_lock makes both mistakes impossible.
Safe swap, multi-resource updates, and layered locks
A deadlock-free swap
template<typename T>
class ThreadSafeContainer {
mutable std::mutex mtx_;
T data_;
public:
void swap(ThreadSafeContainer& other) {
if (this == &other) return;
// deadlock avoidance
std::scoped_lock lock(mtx_, other.mtx_);
std::swap(data_, other.data_);
}
T get() const {
std::scoped_lock lock(mtx_);
return data_;
}
};
Updating several resources atomically
class ResourceManager {
std::mutex cpuMtx_, memMtx_, diskMtx_;
int cpuUsage_, memUsage_, diskUsage_;
public:
void updateAll(int cpu, int mem, int disk) {
std::scoped_lock lock(cpuMtx_, memMtx_, diskMtx_);
cpuUsage_ = cpu;
memUsage_ = mem;
diskUsage_ = disk;
}
void updateCpu(int cpu) {
std::scoped_lock lock(cpuMtx_);
cpuUsage_ = cpu;
}
};
Layered locking
class BankSystem {
std::mutex accountsMtx_;
std::mutex transactionsMtx_;
std::mutex auditMtx_;
public:
void transfer(int from, int to, double amount) {
// lock accounts and transactions
std::scoped_lock lock(accountsMtx_, transactionsMtx_);
// perform transfer
}
void audit() {
// lock all resources
std::scoped_lock lock(accountsMtx_, transactionsMtx_, auditMtx_);
// run audit
}
};
The swap example guards against a.swap(a) for the same reason as the self-transfer check. The ResourceManager shows the trade-off between lock granularity and consistency: updateAll takes all three locks so a reader holding all three sees a consistent snapshot, while updateCpu takes only one lock for the common case. That works because every path that takes more than one of these mutexes takes them in a single scoped_lock call. The deadlock risk returns as soon as one function locks cpuMtx_ and then, inside the critical section, calls another function that locks memMtx_ — two separate acquisitions that std::lock never sees together.
lock_guard vs scoped_lock vs unique_lock (at a glance)
All three are RAII mutex locks, but roles and costs differ.
lock_guard | scoped_lock | unique_lock | |
|---|---|---|---|
| C++ version | C++11 | C++17 | C++11 |
| Number of mutexes | 1 | 1 or more (variadic) | 1 |
Multi-mutex std::lock | manual composition | built in (std::lock) | std::unique_lock + std::lock pattern |
Manual unlock / defer_lock | no | no | yes (defer_lock, try_to_lock, etc.) |
condition_variable | limited with mutex alone | usually use unique_lock with CV | typical pairing |
| Move | no | no | yes |
Choosing
- Single mutex, lock covers the whole block →
lock_guardorscoped_lockwith one argument. Some teams standardize onscoped_lockonly. - Always lock two or more mutexes together →
scoped_lockis simplest and bundles the deadlock-avoidance algorithm. - Unlock mid-scope, relock, wait on condition variables, timed try →
unique_lockis required.
std::mutex m;
std::condition_variable cv;
bool ready = false;
void wait_ready() {
std::unique_lock<std::mutex> lk(m); // cannot replace with scoped_lock
cv.wait(lk, [] { return ready; });
}
Deadlock avoidance: when lock ordering alone is not enough
scoped_lock applies std::lock-style ordering for multiple mutexes taken in the same call. The following situations remain risky:
- Incremental acquisition elsewhere: one thread uses
scoped_lock(m1, m2)while another locksm2with its ownlock_guardand then, still holding it, locksm1in a nested call. The second thread’s two separate acquisitions are outsidestd::lock’s control, so it can holdm2and wait form1forever. - Mixing code that reads shared data without a lock with code that writes under a lock — not a deadlock, but a data race the locks do nothing to prevent.
- Nested locking through callbacks or virtual calls on a non-reentrant mutex when another lock is already held.
The third case is the one I see most in real code, because it hides behind an interface. A class locks its mutex and then invokes a user callback or an observer; the callback calls back into the same object, which tries to lock the same std::mutex again, and the thread deadlocks on itself. Switching to std::recursive_mutex hides the symptom but usually signals a design problem. The more robust rule is to never call unknown code — callbacks, virtual functions, signals — while holding a lock: copy what you need under the lock, release it, then call out.
In production, assigning global order numbers to resources and always locking lower-numbered mutexes first, together with scoped_lock, makes reasoning and review easier. Even though scoped_lock already orders acquisition, explicit ordering rules help lock paths stand out in code review.
Before locking several mutexes at once
- If the same two objects can be locked in different orders, both
scoped_lock(a,b)andscoped_lock(b,a)are safe, but verify that no path locks only a subset in a conflicting way. - When mixing std::shared_mutex with ordinary
mutex, document read/write rules.scoped_lockalways takes exclusive locks (with several mutexes, each must be Lockable, i.e. supporttry_lock); for a shared read lock combined with another mutex, createstd::shared_lockandstd::unique_lockobjects withstd::defer_lockand pass them tostd::lock— or toscoped_lock, since lock objects are themselves lockable. - Minimize hold time. Holding a broad
scoped_lockacross I/O or network calls hurts throughput.
Updating two adjacent graph nodes
When updating edge (u, v) in a graph and both vertex mutexes must be held, lock by vertex id order or use scoped_lock(mtx[u], mtx[v]).
struct Vertex {
std::mutex mtx;
int value{};
};
std::vector<Vertex> graph;
void add_edge_value(size_t u, size_t v, int delta) {
if (u == v) return;
// per-vertex mutexes — order varies; still avoids deadlock
std::scoped_lock lk(graph[u].mtx, graph[v].mtx);
graph[u].value += delta;
graph[v].value += delta;
}
The u == v check again prevents locking one mutex twice. Because std::mutex is neither copyable nor movable, neither is Vertex, so graph must be created with its final size (std::vector<Vertex> graph(n);) — push_back and resize on an existing vector fail to compile, since growing the vector would require moving the elements. When the graph must grow, per-vertex mutexes are usually stored separately, for example in a std::deque or as std::unique_ptr<std::mutex>.
What locking actually costs
- Single
mutex, moderate contention:lock_guardandscoped_lock(mtx)are roughly the same cost (the mutex dominates). - Multiple mutexes:
std::lockmay try_lock, unlock, and retry, so the fast path (all locks on first try) can be slightly better than the worst case. Treat the difference as the price of no deadlocks. - Benchmarking: comparing nanoseconds in isolation matters less than end-to-end latency under real critical-section length and thread count.
In short: simplest option for a single lock, scoped_lock for multiple locks, unique_lock for condition variables and flexible locking.
FAQ
Q1: What is scoped_lock?
A: A RAII lock introduced in C++17 that locks multiple mutexes at once. It helps prevent deadlocks and unlocks automatically.
std::mutex m1, m2;
void func() {
std::scoped_lock lock(m1, m2); // deadlock avoidance
// automatic unlock
}
Q2: How does it prevent deadlock?
A: It uses the std::lock() deadlock-avoidance algorithm internally to acquire multiple mutexes safely.
// Thread 1
std::scoped_lock lock(m1, m2);
// Thread 2
std::scoped_lock lock(m2, m1); // OK: deadlock avoided
Mechanism: a try-lock-based approach locks all mutexes without deadlock.
Q3: How is it different from lock_guard?
A:
- lock_guard: single mutex only
- scoped_lock: single or multiple mutexes
// lock_guard: single only
std::lock_guard<std::mutex> lock(mtx);
// scoped_lock: single or multiple
std::scoped_lock lock1(mtx); // single
std::scoped_lock lock2(m1, m2, m3); // multiple
Recommendation: on C++17 and later, prefer scoped_lock.
Q4: Is it movable?
A: No. scoped_lock is neither copyable nor movable. Use unique_lock if you need move semantics.
std::scoped_lock lock(mtx);
// auto lock2 = std::move(lock); // error
// use unique_lock
std::unique_lock<std::mutex> ulock(mtx);
auto ulock2 = std::move(ulock); // OK
Q5: What about performance?
A: Same as lock_guard for a single mutex. With multiple mutexes there is a small overhead from the avoidance algorithm; for safety it is usually negligible.
// single: same cost as lock_guard
std::scoped_lock lock(mtx);
// multiple: small overhead (deadlock avoidance)
std::scoped_lock lock(m1, m2, m3);
Q6: What about C++11/14?
A: Combine std::lock() with lock_guard.
// C++11/14
std::mutex m1, m2;
void func() {
std::lock(m1, m2); // deadlock avoidance
std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
}
// C++17: much simpler
void func() {
std::scoped_lock lock(m1, m2);
}
Q7: Can I conditionally lock multiple mutexes?
A: Yes, but each branch needs its own scoped_lock.
void func(bool needBoth) {
if (needBoth) {
std::scoped_lock lock(m1, m2);
// lock both
} else {
std::scoped_lock lock(m1);
// lock m1 only
}
}
Q8: Learning resources for scoped_lock?
A:
- C++ Concurrency in Action (2nd ed.) by Anthony Williams
- C++17 — The Complete Guide by Nicolai Josuttis
- cppreference.com — std::scoped_lock
Related posts: Mutex & lock_guard, shared_mutex, data races & mutexes, thread basics.
std::scoped_lock is a C++17 RAII lock that safely locks multiple mutexes without deadlock.