Mutexes and locks
Two threads that write the same variable without coordination lose updates: the read-modify-write of one can sit between the read and the write of the other. A mutex makes the region between lock and unlock run as one piece, which is called the critical section.
Lesson 105 joined threads and promised that the subject of shared data would come next. It comes here: one mutex around a counter, two mutexes taken at once, and a lock that is released before the slow part of a function.
A mutex also carries an ordering promise: the unlock publishes the writes made under the lock and the next lock acquires them, which is the happens-before of lesson 109. The counter is visible to the other thread because of that promise, not because of the increment alone.
One mutex, one counter
std::lock_guard<std::mutex> locks the mutex in its constructor and unlocks it in its destructor, so the region it guards is a block. Two threads run 100000 increments each and the print is 200000, every time.
The increment is three machine operations: read the counter, add one, write it back. Without the guard, the other thread can read the old value between the read and the write, and one of the two writes disappears. The program would print a number below 200000 that changes from run to run.
The guard is the RAII pattern of lesson 027 applied to a lock: the only way to forget the unlock is to leave the block, and then the compiler does it for you.
#include <iostream>
#include <mutex>
#include <thread>
std::mutex guard;
int counter = 0;
void count()
{
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock{guard};
++counter;
}
}
int main()
{
std::thread a{count};
std::thread b{count};
a.join();
b.join();
std::cout << counter << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread counter.cpp -o counter
./counter
200000
Two locks at once
A transfer touches two accounts, and each has its own mutex. Locking one and then the other is the classic deadlock: one thread holds A and waits for B, the other holds B and waits for A.
std::scoped_lock lock{from.guard, to.guard} takes both in one operation and uses the same algorithm as std::lock, which avoids the cycle. The program runs two transfers in opposite directions and prints the final balances.
The totals never change, so the two possible orders of the transfers end at the same pair of numbers. That is the property to test for: a transfer moves money, it does not create or destroy it.
#include <iostream>
#include <mutex>
#include <thread>
struct account
{
explicit account(int start) : balance(start) {}
int balance;
std::mutex guard;
};
void transfer(account& from, account& to, int amount)
{
std::scoped_lock lock{from.guard, to.guard};
from.balance -= amount;
to.balance += amount;
}
int main()
{
account a{100};
account b{0};
std::thread first{[&] { transfer(a, b, 30); }};
std::thread second{[&] { transfer(b, a, 10); }};
first.join();
second.join();
std::cout << a.balance << ' ' << b.balance << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread transfer.cpp -o transfer
./transfer
80 20
Let go before the slow part
A std::unique_lock does what a lock_guard does and can also unlock, relock and be moved. The usual reason to choose it is a critical section that ends before the function does, so the slow work runs without the mutex.
The program copies the value into a vector under the lock, leaves the block, and only then runs a loop that stands for a long computation. Another thread can enter the locked region while the first one is still adding numbers.
The rule is to hold a lock for as short a time as the invariants allow. Everything that does not read or write the shared data belongs outside, which is the same advice as making a critical section small in any language.
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
std::mutex guard;
std::vector<int> values;
int record(int value)
{
{
std::unique_lock<std::mutex> lock{guard};
values.push_back(value);
}
int sum = 0;
for (int i = 0; i <= value; ++i) {
sum += i;
}
return sum;
}
int main()
{
int first = 0;
int second = 0;
std::thread a{[&] { first = record(3); }};
std::thread b{[&] { second = record(4); }};
a.join();
b.join();
std::cout << values.size() << ' ' << first + second << '\n';
}
g++ -std=c++23 -Wall -Wextra -pthread slow.cpp -o slow
./slow
2 16
A mutex cannot be copied
A mutex is a handle to a system object, so copying one would give two objects that claim to be the same lock. The copy constructor is deleted, and a function that takes a mutex by value does not compile.
The program passes a mutex by value and the compiler reports that the copy constructor is deleted. The message names std::mutex and the deleted function, and it appears at the call, not inside the function.
The fix is a reference, std::mutex&, or better, a pointer to the object that owns the mutex and the data together. A lock is never part of a value, which is also why a class with a mutex is not copyable.
#include <mutex>
void lock_me(std::mutex guard)
{
guard.lock();
}
int main()
{
std::mutex guard;
lock_me(guard);
}
g++ -std=c++23 -Wall -Wextra -pthread copy.cpp -o copy
copy.cpp: In function 'int main()':
copy.cpp:12:12: error: use of deleted function 'std::mutex::mutex(const std::mutex&)'
12 | lock_me(guard);
| ~~~~~~~^~~~~~~
In file included from /usr/include/c++/15.2.0/mutex:49,
from copy.cpp:1:
/usr/include/c++/15.2.0/bits/std_mutex.h:109:5: note: declared here
109 | mutex(const mutex&) = delete;
| ^~~~~
copy.cpp:12:12: note: use '-fdiagnostics-all-candidates' to display considered candidates
12 | lock_me(guard);
| ~~~~~~~^~~~~~~
copy.cpp:3:25: note: initializing argument 1 of 'void lock_me(std::mutex)'
3 | void lock_me(std::mutex guard)
| ~~~~~~~~~~~^~~~~
Exercises
- Remove the lock_guard from the counter program and run it several times; explain why the number changes.
- Add a third account and a transfer that touches all three, still with one scoped_lock.
- Replace the unique_lock in slow.cpp with a lock_guard around the push_back and check that the program still compiles and prints the same numbers.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does
std::lock_guarddo?Answer
It locks the mutex in its constructor and unlocks it in its destructor
The guard ties the unlock to the end of its scope, which is the point of the RAII pattern.
-
Why can two threads lose updates when they increment the same int without a lock?
Answer
Because the increment is a read, an addition and a write that can interleave
Both threads can read the same old value, and one of the two writes overwrites the other.
-
What does this program print?
std::mutex m; int c = 0; auto f = [&]{ for (int i = 0; i < 100000; ++i) { std::lock_guard<std::mutex> l{m}; ++c; } }; std::thread a{f}, b{f}; a.join(); b.join(); std::cout << c;Answer
200000
The guard makes each increment one critical section, so every one of the 200000 increments is counted.
-
What does
std::scoped_lock lock{from.guard, to.guard}avoid compared with two separate lock_guards?Answer
A deadlock between threads that take the two mutexes in different orders
Taking both mutexes in one operation uses a deadlock-avoiding algorithm instead of a lock order the caller chose.
-
What does this program print?
#include <iostream> #include <mutex> #include <thread> struct account { explicit account(int start) : balance(start) {} int balance; std::mutex guard; }; void transfer(account& from, account& to, int amount) { std::scoped_lock lock{from.guard, to.guard}; from.balance -= amount; to.balance += amount; } int main() { account a{100}, b{0}; std::thread first([&] { transfer(a, b, 30); }); std::thread second([&] { transfer(b, a, 10); }); first.join(); second.join(); std::cout << a.balance << ' ' << b.balance; }Answer
80 20
Whatever the order, 30 leaves a and 10 leaves b, so the totals settle at 80 and 20.
-
What can a
std::unique_lockdo that astd::lock_guardcannot?Answer
It can be unlocked early, relocked and moved to another owner
The unique_lock keeps the locked state as a member, so unlock, lock and move are all available.
-
What does the compiler say about
void f(std::mutex m)when f is called with a mutex?Answer
That the copy constructor of std::mutex is deleted
Copying a mutex is forbidden, so the by-value parameter cannot be constructed.
-
A critical section copies a shared vector into a local. Why release the lock before the slow computation?
Answer
So other threads can enter the critical section during the slow work
The shared vector is not touched by the computation, so holding the lock would only block the other threads.
-
When is the mutex released in
{ std::lock_guard<std::mutex> lock{m}; ++x; }?Answer
At the closing brace of the block
The destructor of the guard runs at the end of the block and unlocks the mutex there.
-
What does
try_lock()return when another thread holds the mutex?Answer
false, without blocking
try_lock makes one attempt and reports whether it succeeded, which is how a program can skip work instead of waiting.
The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container. The programs whose section shows a compiler error were compiled until that error and were not run.
Article author: Arthur Isaev