5. Concurrency: Threads, Mutexes & Atomics¶
What & why¶
Modern CPUs have many cores; a single-threaded program uses one. C++ gives you real OS threads with no global interpreter lock — true parallelism — and also no safety net. A data race is undefined behavior. This lesson covers the tools, the rules that make them correct, and the memory model underneath.
Threads¶
#include <iostream>
#include <thread>
#include <vector>
void work(int id) {
std::cout << "worker " << id << '\n'; // output may interleave!
}
int main() {
std::vector<std::jthread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(work, i);
}
} // each jthread joins automatically in its destructor
Compile with -pthread on Linux (CMake: find_package(Threads) + Threads::Threads).
std::jthread vs. std::thread¶
std::threadmust bejoin()ed ordetach()ed before destruction, or the program callsstd::terminate.std::jthread(C++20) joins automatically (RAII) and supports cooperative cancellation viastd::stop_token.
Prefer std::jthread. Avoid detach() — a detached thread that outlives main or the data it uses is a crash waiting to happen.
Cancellation with std::stop_token¶
#include <chrono>
#include <thread>
std::jthread poller{[](std::stop_token st) {
while (!st.stop_requested()) {
poll_once();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}};
// ...
poller.request_stop(); // also requested automatically by jthread's destructor
Passing arguments¶
Arguments are copied into the thread. To pass by reference, use std::ref — and make sure the referent outlives the thread:
Lambdas capturing by reference have the same lifetime obligation.
Data races¶
A data race: two threads access the same memory location concurrently, at least one writes, and there's no synchronization. It's undefined behavior — not just "wrong answer".
int counter = 0;
void increment() {
for (int i = 0; i < 100'000; ++i) {
++counter; // read-modify-write: RACE
}
}
std::jthread a{increment}, b{increment};
// after joining, counter is often less than 200000 — or anything else (UB)
Compile with -fsanitize=thread and ThreadSanitizer reports it immediately. Use TSan on all concurrent code.
Mutexes¶
A mutex ensures only one thread at a time runs a critical section:
#include <mutex>
std::mutex m;
int counter = 0;
void increment() {
for (int i = 0; i < 100'000; ++i) {
std::lock_guard lock{m}; // locks here...
++counter;
} // ...unlocks here (RAII)
}
Never call m.lock() / m.unlock() manually — an exception or early return leaves it locked forever. Use the RAII lock types:
| Type | Use |
|---|---|
std::lock_guard |
simplest; lock for the scope |
std::scoped_lock |
lock several mutexes at once without deadlock |
std::unique_lock |
movable, can unlock early/relock; required by condition variables |
std::shared_lock + std::shared_mutex |
many readers or one writer |
Encapsulate the lock with the data¶
Don't leave a mutex and "the data it protects" as separate globals. Bundle them:
#include <map>
#include <mutex>
#include <optional>
#include <string>
class ThreadSafeCache {
public:
void put(const std::string& key, std::string value) {
std::lock_guard lock{mutex_};
data_[key] = std::move(value);
}
std::optional<std::string> get(const std::string& key) const {
std::lock_guard lock{mutex_};
if (auto it = data_.find(key); it != data_.end()) {
return it->second; // return a COPY, not a reference into data_
}
return std::nullopt;
}
private:
mutable std::mutex mutex_; // mutable: lockable in const methods
std::map<std::string, std::string> data_;
};
Never return references or pointers to protected data — the caller would use them without the lock.
Deadlock¶
Thread 1 locks A then B; thread 2 locks B then A → both wait forever. Defenses:
- Lock multiple mutexes together with
std::scoped_lock lock{a, b};. - Establish a global lock order.
- Don't call unknown code (callbacks, virtual functions) while holding a lock.
- Keep critical sections short.
Condition variables¶
Wait efficiently until some condition becomes true — the basis of producer/consumer queues:
#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>
template <typename T>
class BlockingQueue {
public:
void push(T value) {
{
std::lock_guard lock{mutex_};
queue_.push(std::move(value));
}
cv_.notify_one();
}
// Blocks until an item is available or close() is called.
std::optional<T> pop() {
std::unique_lock lock{mutex_};
cv_.wait(lock, [this] { return !queue_.empty() || closed_; }); // handles spurious wakeups
if (queue_.empty()) {
return std::nullopt; // closed and drained
}
T value = std::move(queue_.front());
queue_.pop();
return value;
}
void close() {
{
std::lock_guard lock{mutex_};
closed_ = true;
}
cv_.notify_all();
}
private:
std::mutex mutex_;
std::condition_variable cv_;
std::queue<T> queue_;
bool closed_ = false;
};
Always wait with a predicate — condition variables can wake spuriously.
Atomics¶
For single variables, std::atomic<T> provides lock-free, race-free operations:
#include <atomic>
std::atomic<int> counter{0};
void increment() {
for (int i = 0; i < 100'000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed); // or simply ++counter
}
}
Operations: load, store, exchange, fetch_add, fetch_sub, compare_exchange_weak/strong, plus C++20 wait/notify_one/notify_all.
Atomics make individual operations atomic, not sequences. if (x.load() == 0) x.store(1); is still a race between the check and the store — use compare_exchange:
The memory model in brief¶
Compilers and CPUs reorder memory operations. Atomics with memory orders control what other threads are guaranteed to see:
| Order | Guarantees | Typical use |
|---|---|---|
seq_cst (default) |
one global order all threads agree on | default; start here |
acquire / release |
writes before a release store are visible after an acquire load that reads it |
publishing data, locks |
relaxed |
atomicity only, no ordering | counters, statistics |
Publishing data with release/acquire:
std::string payload;
std::atomic<bool> ready{false};
// producer
payload = "hello";
ready.store(true, std::memory_order_release);
// consumer
while (!ready.load(std::memory_order_acquire)) { /* spin or wait */ }
std::cout << payload; // guaranteed to see "hello"
Use the default seq_cst unless profiling proves it matters and you can prove the weaker order correct. Lock-free programming beyond counters and flags is expert territory.
Other tools¶
std::call_once/ function-localstatic— thread-safe one-time initialization.thread_local— one variable instance per thread.std::latch— one-shot countdown: wait for N events.std::barrier— reusable rendezvous point for a group of threads, per phase.std::counting_semaphore— limit concurrent access to N.std::atomic<std::shared_ptr<T>>(C++20) — atomically swap shared state.- Parallel algorithms:
std::sort(std::execution::par, v.begin(), v.end());(<execution>; on GCC requires linking TBB).
How many threads?¶
For CPU-bound work, about one thread per core. More threads than cores just adds context switching. For I/O-bound work, see the next lesson.
False sharing¶
Two threads writing to different variables that share a 64-byte cache line slow each other down dramatically:
struct Counters {
alignas(std::hardware_destructive_interference_size) std::atomic<long> a;
alignas(std::hardware_destructive_interference_size) std::atomic<long> b;
};
(std::hardware_destructive_interference_size is in <new>; use 64 if your standard library lacks it.)
Common mistakes¶
- Data races on "just a flag" or "just a counter" — use
std::atomic. - Manual
lock()/unlock(). - Returning references to mutex-protected data.
- Lock ordering deadlocks; calling callbacks while holding a lock.
- Condition variable
waitwithout a predicate; notifying without changing state under the lock. - Detached threads outliving their data.
- Capturing locals by reference in a thread lambda that outlives the scope.
- Check-then-act on atomics without compare-exchange.
- Not running ThreadSanitizer.
Exercises¶
- Reproduce the racy counter, run it under
-fsanitize=thread, and fix it three ways:std::mutex,std::atomic<int>, and per-thread local counts summed at the end. Benchmark all three. - Implement
BlockingQueue<T>with a capacity limit (pushblocks when full) and test it with 4 producers and 4 consumers under TSan. - Parallel sum: split a
std::vector<double>of 100 million elements acrosshardware_concurrency()std::jthreads. Measure the speedup. Then trystd::reduce(std::execution::par, ...). - Write a deadlock with two mutexes, observe it hang, then fix it with
std::scoped_lock. - Use
std::latchso that N worker threads all start at the same instant after setup completes. - Demonstrate false sharing: two threads incrementing adjacent atomics vs. cache-line-separated atomics. Measure.
Recap¶
std::jthread= RAII threads with cooperative cancellation (std::stop_token).- A data race is undefined behavior; ThreadSanitizer finds them.
- Protect shared data with a mutex via
lock_guard/scoped_lock/unique_lock; bundle the mutex with its data. - Condition variables wait for state changes — always with a predicate.
std::atomicfor single variables; defaultseq_cst; acquire/release for publishing.- Watch for deadlocks, false sharing, and lifetimes of data threads reference.