CodeGym /Courses /C++ SELF /std::unique_lock and std::scoped_lock — when to use

std::unique_lock and std::scoped_lock — when to use

C++ SELF
Level 69, Lesson 2
Available

1. One std::lock_guard is sometimes not enough

If you look at std::lock_guard as a “lock on a door”, it’s a lock without a key: you close the door and can only open it when you leave the room. For many cases that’s perfect: the fewer ways you can “play” with the lock, the fewer chances to hurt yourself.

But there are two typical scenarios.

First: inside a function there is a small piece where you need to safely take data, and then a long job starts — computations, string formatting, sorting, preparing a report. Holding the mutex for the whole time artificially slows other threads. You want to do: “quickly take → quickly release → think for a long time without the lock → quickly return the result”.

Second: the operation touches two protected objects. For example, transferring money between two accounts: you need to change balances on both sides so that no one sees an “intermediate” state. Here the task is “acquire two mutexes in one logical operation”.

The two scenarios are exactly why std::unique_lock and std::scoped_lock exist.

std::unique_lock: an RAII lock you can release and reacquire

std::unique_lock<Mutex> is an RAII wrapper like lock_guard, but smarter and more flexible. Under the hood the idea is the same: object lives — lock is held, object destroyed — lock is released. But unique_lock adds control over the hold time: you can call unlock() in the middle and, if needed, call lock() again.

Keep in mind the philosophy: unique_lock is not “better”, but “more dangerous and more necessary”. It gives more power — and power, as we know, corrupts even junior programmers.

Minimal skeleton looks like this:

#include <mutex>

std::mutex m;

void f() {
    std::unique_lock<std::mutex> lock(m); // locked
    // critical section
    lock.unlock();                        // unlocked before end of scope
    // non-critical section
} // if still locked — will be unlocked in destructor

Note that unique_lock exists not for “beauty” but for scenarios where the boundary of the critical section does not match the boundary of the block { ... }.

2. The “snapshot → compute → commit” pattern with std::unique_lock

Now a small but very practical example: multiple threads append numbers to a shared list (call it “metrics”), and periodically we build a report. Building the report is slow (for example, summing, formatting), and locking the shared mutex for the whole time is a bad idea.

We start with the simplest “daily app template”: a shared Metrics container and a worker thread that adds values. Everything is intentionally simplified: no task queues or event waits — those will be in other lessons.

Shared data and worker thread

#include <mutex>
#include <vector>

std::mutex metrics_m;
std::vector<int> metrics;

void push_metric(int x) {
    std::lock_guard<std::mutex> lock(metrics_m);
    metrics.push_back(x);
}

Here lock_guard is ideal: we want exactly “take → push → leave”. No tricks.

Building the report: copy under lock, compute without lock

Now the report function. First take a “snapshot” under protection, then release the mutex and compute for as long as needed.

#include <mutex>
#include <numeric>
#include <vector>

std::vector<int> snapshot_metrics() {
    std::unique_lock<std::mutex> lock(metrics_m);
    std::vector<int> copy = metrics; // quickly copied
    lock.unlock();                   // released the mutex
    return copy;
}

Note: we do not try to “read metrics without protection”. We honestly make a copy and then work with local data. This is a key concurrency trick: if you can turn “shared data” into “local data” — you drastically reduce the number of problems.

Now compute sum and average on the copy:

#include <iostream>
#include <numeric>
#include <vector>

void print_report(const std::vector<int>& data) {
    const int sum = std::accumulate(data.begin(), data.end(), 0);
    std::cout << "count=" << data.size() << " sum=" << sum << '\n';
    // for example: count=10 sum=42
}

And together it looks like a clear flow:

flowchart TD
    A[lock] --> B[copy metrics]
    B --> C[unlock]
    C --> D[compute on local copy]
    D --> E[print / format]

The point of unique_lock here is exactly that the lock boundary is “where it makes semantic sense”, not “where the function ends”.

Why not just use a block { ... }?

Good question: sometimes you can.

std::vector<int> copy;
{
    std::lock_guard<std::mutex> lock(metrics_m);
    copy = metrics;
} // lock_guard destroyed — mutex released

This works and is often even preferable because it’s simpler. But unique_lock becomes useful when you have more complex logic: early returns, multiple phases, conditional holding of the lock, or the need to relock the mutex later.

4. Deferred acquisition: std::defer_lock

Sometimes in a function we create a lock object in advance, but want to acquire the mutex only if we actually get to the place where it’s needed. For example, we have a fast exit path: “if there is no data — return”, and we don’t want to lock unnecessarily.

For that unique_lock has a “deferred locking” mode — create the object but don’t take the mutex immediately.

#include <mutex>

extern std::mutex metrics_m;

void maybe_touch_metrics(bool need) {
    std::unique_lock<std::mutex> lock(metrics_m, std::defer_lock); // not locked yet

    if (!need) {
        return; // and that's fine: we never took the lock
    }

    lock.lock(); // now locked
    // work with shared data
}

Be honest with yourself: yes, flexibility is useful. But this is where mistakes of the “I thought it was already locked” level begin. So defer_lock is not a daily tool, but for cases where you feel the code would otherwise be ugly or would hold the lock too early.

5. std::scoped_lock: one RAII object for multiple mutexes

If unique_lock solves the problem of “how long to hold a lock”, then std::scoped_lock solves the problem “I need to acquire multiple mutexes in one operation”.

The idea is simple: create one scoped_lock, pass several mutexes to it, and it will hold them all while it lives. This is better than two lock_guards in a row, because two separate guards create the temptation to acquire mutexes in different orders in different places, which smells like trouble.

(You will study deadlocks in detail in the next lecture — today it’s enough to learn “how to properly hold two locks”, without diving deep into the mystery.)

Mini-model: two accounts and a transfer between them

We make a simple account structure: balance + its own mutex.

#include <mutex>

struct Account {
    int balance = 0;
    std::mutex m;
};

A money transfer is an operation on two accounts. So we need two mutexes at once:

#include <mutex>

void transfer(Account& from, Account& to, int amount) {
    std::scoped_lock lock(from.m, to.m);
    from.balance -= amount;
    to.balance += amount;
}

The code is short and reads honestly: “to transfer you must protect both objects simultaneously”. And, important for a beginner, the critical section boundary is again the lifetime of the variable lock.

If there are more than two mutexes

Syntactically nothing scary: scoped_lock accepts multiple mutexes.

std::scoped_lock lock(m1, m2, m3);

From a design perspective — this is a reason to think: “is my operation doing too many things at once?”. But the tool itself supports it.

6. Small decision table: lock_guard vs unique_lock vs scoped_lock

When there are more tools, the brain tends to panic (that’s normal). A simple decision table helps — not as doctrine, but as a cheat sheet.

Tool When to use What it does Cost of mistake
std::lock_guard
“Lock for the whole scope and forget” only RAII holding minimal: hard to forget unlock()
std::unique_lock
Need unlock()/lock() inside the function or deferred acquisition early unlock(), relock with lock(), defer_lock higher: you can mix up whether it's currently locked
std::scoped_lock
Need to hold multiple mutexes in one operation RAII for multiple mutexes high, if trying to do it manually in different orders

The general principle remains: start with the simplest (lock_guard), and only switch to a more flexible tool when there is a concrete need. In the world of threads “overly clever code” usually means “a future bug that’s still shy”.

Review questions

  1. In which scenario is std::unique_lock more useful than a block { ... } with std::lock_guard?
  2. Why does the “snapshot → compute → commit” pattern often speed up multithreaded code even without special optimizations?
  3. Why does transferring money between two objects require acquiring two mutexes at once?
  4. What problem does std::scoped_lock solve compared to “two lock_guards in a row”?
  5. Why can std::defer_lock make code both prettier and more dangerous at the same time?

7. Common mistakes when using std::unique_lock and std::scoped_lock

Mistake #1: using std::unique_lock “everywhere just in case”.
This is a frequent reflex: since unique_lock can do more, it must be “more universal”. In practice the opposite happens: you spread a potentially dangerous tool through the codebase, and later it’s hard to tell by eye where the mutex is held and where it’s released. The right habit: default to lock_guard, and use unique_lock only where it truly helps.

Mistake #2: calling unlock() without restoring invariants.
The sneakiest logical mistake: you changed part of the protected state, released the mutex, and then “finish the job”. In a multithreaded world “then” might never come or might come too late: another thread will see a half-built state. If you release the mutex mid-function, release it only when the protected data are again consistent.

Mistake #3: after unlock() continue to read/write shared data out of habit.
It looks like: you took unique_lock, read sharedValue, did unlock(), and then further down you use sharedValue as if it were still current and protected. This is not a compile error but a reasoning error. Discipline helps: after unlock() work only with local copies, and if you need the shared resource again — explicitly call lock() and re-enter the critical section.

Mistake #4: acquiring multiple mutexes manually in different orders across functions.
We did not cover deadlock in depth today, but even without the theory you can see: if in one place you take mA then mB, and elsewhere you do the opposite, you create conditions for “eternal waiting”. std::scoped_lock is good because it makes the intention “take everything at once” explicit and reduces the risk of lock protocol errors.

Mistake #5: holding std::scoped_lock too long and doing “slow” work under it.
scoped_lock easily “expands” the critical section: two mutexes are held simultaneously, so you block more threads and more code. If heavy string formatting or I/O accidentally ends up inside such a section, you will get a bottleneck. Same habit: under lock do only what truly must be mutually exclusive.

1
Task
C++ SELF, level 69, lesson 2
Locked
Metrics snapshot
Metrics snapshot
1
Task
C++ SELF, level 69, lesson 2
Locked
On-demand reading
On-demand reading
1
Task
C++ SELF, level 69, lesson 2
Locked
Two accounts exchanging funds
Two accounts exchanging funds
1
Task
C++ SELF, level 69, lesson 2
Locked
Processing parcels
Processing parcels
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION