C++ / a working model

72 / 163   ·   C++17   ·   10 min

Mutual exclusion and RAII: mutex, scoped_lock, and deadlock

Keep this sentence

A mutex protects the invariant of a set of shared state, not a variable name. Bind the lock's lifetime with RAII. When several objects must be updated together, acquire every required lock in one step. Avoid lock-order cycles, and avoid waiting for a thread or calling unknown code while a lock is held.

In this lesson
  1. A lock protects a whole operation, not a single read or write
  2. Choose the RAII lock that matches the needed capability
  3. Avoiding one deadlock locally does not make the whole program deadlock-free
  4. Example
  5. Exercise

A lock protects a whole operation, not a single read or write

When several threads access the same ordinary mutable state, name the lock that owns it and make every conflicting access follow that protocol. Locking only the writes while the reads run unsynchronized can still be a data race. An unlock synchronized-with a later lock of the same mutex, so a later critical section can observe earlier modifications.

Take a transfer between accounts. Debit and credit together keep the total balance unchanged. Locking each assignment on its own is not enough: another thread can observe a debit without the matching credit. Check the condition and modify both ends inside one critical section. The invariant of the shared state decides the lock's scope. Do not place a lock mechanically on every line.

Choose the RAII lock that matches the needed capability

lock_guard locks in its constructor and unlocks in its destructor. It fits a fixed critical section. unique_lock can be moved, can delay locking, and can unlock temporarily. Waiting on a condition variable needs that transferable lock state. Both release an acquired lock on early return and on exception unwind, so handwritten lock/unlock pairs are not left unmatched.

C++17 scoped_lock can manage several locks at once and acquires them with a deadlock-avoidance algorithm. The example hands it both account mutexes in one step. Another thread that transfers with the arguments reversed does not form this two-lock deadlock. The lock object must have a name. A temporary is destroyed at the end of the statement, and later access is already unprotected.

Avoiding one deadlock locally does not make the whole program deadlock-free

A common deadlock is that one thread holds A and waits for B while another holds B and waits for A. A single lock hierarchy, or acquiring the complete set of needed locks together, removes that cycle. Do not pass the same non-recursive mutex to scoped_lock twice. The example recognizes a transfer to self and returns, so the same object is not locked twice.

scoped_lock does not analyze every external dependency. If you hold a lock and then call a callback, wait for a future, or join, and the waited party needs that lock, you can still deadlock. Move expensive work out of the critical section. Keep only the check and the commit of state.

The example transfers one unit one hundred times on each side. The initial balances cover any interleaving, and the final result does not depend on scheduling. The main thread reads the balances after join, when there are no concurrent writes.

Pitfalls

  • Using two different mutexes for reads and writes of the same balance does not establish mutual exclusion; the protocol must use the same lock.
  • recursive_mutex only lets the same thread lock again. It does not remove wait cycles across threads, and it does not replace a clear interface boundary.

Run an example

Minimum C++17 · complete program · Download .cpp

#include <cassert>
#include <iostream>
#include <mutex>
#include <thread>

struct Account {
    std::mutex mutex;
    int balance = 1000;
};

void transfer_one(Account& from, Account& to) {
    if (&from == &to) return;
    std::scoped_lock lock(from.mutex, to.mutex);
    assert(from.balance > 0);
    --from.balance;
    ++to.balance;
}

int main() {
    Account a;
    Account b;
    std::thread worker([&] {
        for (int i = 0; i < 100; ++i) transfer_one(a, b);
    });
    for (int i = 0; i < 100; ++i) transfer_one(b, a);
    worker.join();
    assert(a.balance == 1000 && b.balance == 1000);
    transfer_one(a, a);
    assert(a.balance + b.balance == 2000);
    std::cout << a.balance << ' ' << b.balance << '\n';
}

Compile locally

g++ -std=c++17 -Wall -Wextra -Wpedantic -pthread concurrency-mutex.cpp -o example && ./example

Expected result

1000 1000

CHECK YOUR UNDERSTANDING

Close the answer. Explain it.

If a function is added that reads the combined balance of two accounts, may it lock and read each account separately and then add? Give a consistent-snapshot version.

Show a reference answer

That does not guarantee a consistent snapshot: a transfer may complete between the two reads, so the balances come from different states. For distinct accounts, use std::scoped_lock lock(a.mutex, b.mutex) and return a.balance + b.balance while both locks are held. If the interface allows both arguments to name the same account, handle aliasing first; do not pass the same mutex twice.

Check the sources

Drafts and official chapters change. The version mark is only the example’s minimum.

Back to the catalog