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:

  1. Lock the first mutex.
  2. Try to lock the rest with try_lock().
  3. On failure, unlock all and retry.
  4. 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_guardscoped_lockunique_lock
C++ versionC++11C++17C++11
Number of mutexes11 or more (variadic)1
Multi-mutex std::lockmanual compositionbuilt in (std::lock)std::unique_lock + std::lock pattern
Manual unlock / defer_locknonoyes (defer_lock, try_to_lock, etc.)
condition_variablelimited with mutex aloneusually use unique_lock with CVtypical pairing
Movenonoyes

Choosing

  • Single mutex, lock covers the whole block → lock_guard or scoped_lock with one argument. Some teams standardize on scoped_lock only.
  • Always lock two or more mutexes together → scoped_lock is simplest and bundles the deadlock-avoidance algorithm.
  • Unlock mid-scope, relock, wait on condition variables, timed try → unique_lock is 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:

  1. Incremental acquisition elsewhere: one thread uses scoped_lock(m1, m2) while another locks m2 with its own lock_guard and then, still holding it, locks m1 in a nested call. The second thread’s two separate acquisitions are outside std::lock’s control, so it can hold m2 and wait for m1 forever.
  2. 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.
  3. 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) and scoped_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_lock always takes exclusive locks (with several mutexes, each must be Lockable, i.e. support try_lock); for a shared read lock combined with another mutex, create std::shared_lock and std::unique_lock objects with std::defer_lock and pass them to std::lock — or to scoped_lock, since lock objects are themselves lockable.
  • Minimize hold time. Holding a broad scoped_lock across 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_guard and scoped_lock(mtx) are roughly the same cost (the mutex dominates).
  • Multiple mutexes: std::lock may 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:

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.