C++ / CONCURRENCY AND PARALLELISM
Mutexes and guarding shared state
Guard related variables with one std::mutex so every thread sees only consistent states, and size critical sections around the invariant they restore.
What you will learn
- Lock the same std::mutex for every read and every write of the data it guards
- Extend a critical section to cover a whole check-then-act, not just the write
- Store the mutex beside the data it protects and pass that object by reference
- Use try_lock to skip work, and touch nothing guarded when it returns false
Understanding Mutexes and guarding shared state
A std::mutex is a small piece of state with exactly one owner at a time. m.lock() blocks the calling thread until it becomes the owner, and m.unlock() gives ownership up. Nothing about the mutex names the data it protects: the association between t.m and t.sum/t.count exists only because every function that touches those fields locks that mutex first. Mutual exclusion is a property of the discipline in your code, not of the mutex object.
What you are guarding is an invariant, not a variable. In add_range the invariant "count is the number of values added into sum" is false for the instant between the two assignments, and that is acceptable precisely because no other thread can be inside the critical section to observe it. That gives the mental model: lock() is stepping into a room where you may temporarily make the data inconsistent, and unlock() is the promise that you put it back. A critical section must therefore stretch across the whole operation from one consistent state to the next, including check-then-act sequences where the result of the check would otherwise go stale.
Two consequences follow. Readers must take the same mutex, because a thread that reads sum and count without locking can catch them mid-update and has no guarantee of seeing the writes at all. And because everything inside a mutex is serialized, hold it only for the accesses that must not interleave: compute values, parse input and format strings outside the lock. Practically this means putting the mutex next to the data it guards as a member of the same struct and passing that object by reference, since std::mutex is neither copyable nor movable and the compiler rejects accidental copies.
<functional>
<iostream>
<mutex>
<thread>
<vector>
struct Tally {
std::mutex m;
long long sum = 0;
long long count = 0; // invariant: count values have gone into sum
};
void add_range(Tally& t, long long first, int n) {
for (int i = 0; i < n; ++i) {
t.m.lock(); // block until this thread owns the mutex
t.sum += first + i;
t.count += 1; // both fields move in one critical section
t.m.unlock(); // release ownership, publish both writes
}
}
int main() {
Tally t;
std::vector<std::thread> workers;
for (int k = 0; k < 4; ++k)
workers.emplace_back(add_range, std::ref(t), k * 1000 + 1, 1000);
for (std::thread& w : workers) w.join();
t.m.lock();
long long sum = t.sum;
long long count = t.count;
t.m.unlock();
std::cout << "count = " << count << '\n';
std::cout << "sum = " << sum << '\n';
std::cout << "mean = " << static_cast<double>(sum) / count << '\n';
}
A mutex protects an invariant rather than a variable, and only if every thread takes the same mutex for the entire span in which that invariant may be broken.
Worked examples
Check-then-act inside one lock
Shows that the search and the insert must share a single critical section, or two threads both conclude the value is absent.
<algorithm>
<iostream>
<mutex>
<string>
<thread>
<vector>
class Registry {
public:
bool add_if_absent(const std::string& name) {
m_.lock();
bool inserted = false;
if (std::find(names_.begin(), names_.end(), name) == names_.end()) {
names_.push_back(name);
inserted = true;
}
m_.unlock();
return inserted;
}
std::size_t size() {
m_.lock();
std::size_t n = names_.size();
m_.unlock();
return n;
}
private:
std::mutex m_;
std::vector<std::string> names_;
};
int main() {
Registry r;
std::vector<char> inserted(8, 0);
std::vector<std::thread> ts;
for (int i = 0; i < 8; ++i)
ts.emplace_back([&r, &inserted, i] {
inserted[i] = r.add_if_absent("alice") ? 1 : 0;
});
for (std::thread& t : ts) t.join();
int winners = 0;
for (char c : inserted) winners += c;
std::cout << "threads that inserted: " << winners << '\n';
std::cout << "registry size: " << r.size() << '\n';
}
Example explained
Line 1add_if_absent holds m_ across both the std::find and the push_back, so no other thread can insert in the gap between them.
Line 2Because the decision and the mutation are one critical section, exactly one of the eight threads sees an empty registry and returns true.
Line 3size() locks as well: reading names_.size() while another thread is inside push_back could observe a container mid-reallocation.
Line 4Each thread writes its own element of inserted, which are distinct memory locations, so that result vector needs no mutex.
try_lock and the skip path
Demonstrates a non-blocking attempt to take the mutex and the rule that failing to acquire means no access at all.
<iostream>
<mutex>
<thread>
std::mutex m;
int shared = 0;
void try_to_bump(const char* who) {
if (m.try_lock()) {
shared += 1;
std::cout << who << ": acquired, shared = " << shared << '\n';
m.unlock();
} else {
std::cout << who << ": mutex busy, skipped the update\n";
}
}
int main() {
m.lock(); // main deliberately owns the mutex
std::thread t(try_to_bump, "worker");
t.join(); // the worker ran while it was owned
m.unlock();
m.lock(); // block instead of trying
shared += 1;
std::cout << "main: blocked and got it, shared = " << shared << '\n';
m.unlock();
}
Example explained
Line 1main locks before spawning and unlocks after joining, so the worker's try_lock is guaranteed to find the mutex owned and return false.
Line 2try_lock returns immediately instead of blocking, which is only useful when the caller has a sensible alternative, here skipping the update.
Line 3The else branch touches neither shared nor unlock(): without ownership the thread may not even read the guarded variable.
Line 4unlock() appears only in the branch that acquired, because unlocking a mutex this thread does not own is undefined behaviour.
A consistent snapshot of two fields
Shows a reader taking both fields of an invariant under a single lock so it never observes a half-finished update.
<iostream>
<mutex>
<thread>
struct Gauge {
std::mutex m;
int value = 0;
int doubled = 0; // invariant: doubled == 2 * value
};
Gauge g;
int violations = 0; // only the checker thread writes this
void writer(int n) {
for (int i = 0; i < n; ++i) {
g.m.lock();
g.value += 1; // invariant broken here...
g.doubled = 2 * g.value; // ...restored here
g.m.unlock();
}
}
void checker(int n) {
for (int i = 0; i < n; ++i) {
g.m.lock();
int v = g.value;
int d = g.doubled; // one lock, both fields
g.m.unlock();
if (d != 2 * v) ++violations;
}
}
int main() {
std::thread w1(writer, 50000);
std::thread w2(writer, 50000);
std::thread c(checker, 50000);
w1.join();
w2.join();
c.join();
std::cout << "value = " << g.value << '\n';
std::cout << "doubled = " << g.doubled << '\n';
std::cout << "violations = " << violations << '\n';
}
Example explained
Line 1Between g.value += 1 and the write to g.doubled the invariant is false, and hiding exactly that window is the mutex's job.
Line 2The checker copies both fields under one lock, so it always sees a pair from before or after an update, never halfway through.
Line 3Splitting the checker into two lock/unlock pairs, one per field, would report violations even though every single access is locked.
Line 4The reads in main need no lock because the three join() calls already order the workers' writes before them.
Important notes
join() orders a worker's writes before the joining thread's reads, so a read after join needs no lock; the lock matters only while another thread could still be inside a critical section.
A std::mutex must be unlocked by the thread that locked it, and manual unlock() is easy to skip on an early return or a thrown exception, which leaves the mutex locked forever.
Common mistakes
Locking the writes but reading without the lock: the reader can see sum already bumped while count is not, so its average is wrong, and the unsynchronized read is undefined behaviour on top of that.
Declaring the std::mutex inside the function each thread runs: every thread then locks its own local mutex, so nothing is excluded and the data corrupts while the code looks properly locked.
Calling one locked public method from another: std::mutex is not recursive, so locking it again on a thread that already owns it is undefined behaviour and in practice hangs that thread.
Try it yourself
Change, predict, then run
Write a struct holding std::vector<int> samples, long long sum and a std::mutex, with a push(int) that keeps sum equal to the total of samples. Start three threads pushing 1000 values each, then after joining print sum and std::accumulate over samples and confirm they match.
Open the C++ workspaceCheck your understanding
A shared list is guarded by one mutex: contains(x) locks, searches and unlocks, and insert(x) locks, appends and unlocks. Several threads each run "if (!list.contains(42)) list.insert(42);". What happens?
- 42 is inserted exactly once, because both operations lock the same mutex.
- 42 can be inserted more than once, because the mutex is released between the check and the insert and another thread can insert in that gap.
- The program has a data race and is undefined behaviour, because several threads call contains at the same time.
- The threads deadlock, because insert tries to lock the mutex that contains is still holding.
Show answer
Each call is a correct critical section, so there is neither a data race nor a deadlock: contains unlocks before insert locks. The tempting first option fails because atomicity does not compose, so the answer from contains is already stale by the time insert runs. The fix is a single add_if_absent that holds the mutex across both the search and the append.