C++ / Advanced / Lesson 17
Concurrency and synchronization
Build race-free shared-state operations with mutexes and atomics, and distinguish deterministic final results from scheduling-dependent execution order.
On this page
Lesson goals
- Identify conflicting accesses and explain data-race undefined behavior.
- Protect a multi-field invariant with RAII locking.
- Use atomic counters without mistaking them for full synchronization.
A data race is not merely a lost update
Threads can execute concurrently and observe shared memory through the C++ memory model. Conflicting accesses include a write and another read or write to the same memory location. Potentially concurrent conflicting non-atomic actions without the required happens-before ordering can create a data race, which is undefined behavior. Do not assign a range of possible counter outputs to such a program as though it were a valid randomized calculation. Scheduling-dependent order can be well-defined in a correctly synchronized program; undefined behavior and ordinary nondeterminism are different claims.
Protect the whole state transition
A mutex protects an invariant only when every relevant access follows the same locking discipline. std::lock_guard acquires in its constructor and releases in its destructor, including on exceptions. The first example updates two related fields under one lock, preserving equality. The worker threads do not print, so final output is independent of scheduling. A nested scope destroys both C++20 jthreads before reading the counters; their destructors join when joinable. On GCC/Linux, compile these complete examples with -pthread as well as -std=c++20. Check the target toolchain thread support on other platforms.
#include <iostream>
#include <mutex>
#include <thread>
int main() {
int items{};
int recorded{};
std::mutex mutex;
auto work = [&] {
for (int i{}; i < 1000; ++i) {
std::lock_guard<std::mutex> lock{mutex};
++items;
++recorded;
}
};
{ std::jthread first{work}; std::jthread second{work}; }
std::cout << items << " " << recorded << "\n";
}
2000 2000
Every increment pair is protected. Both joins complete before the final reads, so exactly two thousand operations are reflected in each field.
Atomicity has a precise scope
std::atomic<int> supports indivisible operations on one integer. A read-modify-write such as fetch_add combines observation and update atomically; a separate load followed by store can still lose logical increments even when both accesses are atomic. The relaxed counter example needs atomicity but uses no counter value to publish unrelated data. Thread completion and joining precede the final read. Sequential consistency is the default and is a useful starting point; choose weaker ordering only with a written argument about which synchronization is unnecessary. Atomic does not necessarily mean lock-free.
#include <atomic>
#include <iostream>
#include <thread>
int main() {
std::atomic<int> count{0};
auto work = [&] {
for (int i{}; i < 1000; ++i) {
count.fetch_add(1, std::memory_order_relaxed);
}
};
{ std::jthread first{work}; std::jthread second{work}; }
std::cout << count.load(std::memory_order_relaxed) << "\n";
}
2000
Two thousand atomic increments are retained. The example makes no claim that relaxed operations publish other non-atomic state.
Coordinate conditions and shutdown
Use condition variables with a predicate protected by the associated mutex, because wakeups can be spurious and notifications are not stored as durable messages. For multiple mutexes, std::scoped_lock can acquire them together using deadlock-avoidance behavior. Keep critical sections short and avoid invoking unknown callbacks while locked. jthread requests stop and joins during destruction, but cancellation is cooperative: worker code must observe a stop token or another shutdown condition. A joining destructor can wait indefinitely if the worker ignores the protocol or waits on a condition that is never released.
Make publication and verification explicit
Publishing ordinary data through an atomic flag requires an appropriate synchronization relationship, commonly a release store observed by an acquire load, and a resource lifetime that covers the read. A relaxed flag alone does not supply that relationship. Start with mutexes for compound state unless a clear requirement justifies lock-free design. Run ThreadSanitizer separately on supported platforms and exercise shutdown paths repeatedly; a clean run does not prove all interleavings safe. Review lock order, captured references, task lifetimes, and exception handling inside workers. An uncaught exception escaping a thread function terminates the process.
Common errors and fixes
An unsynchronized counter is described as merely inaccurate.
- Why it happens
- Data-race undefined behavior was overlooked.
- How to fix it
- Use one locking discipline or a suitable atomic operation.
- Prevent it next time
- Classify every shared read and write before running.
Atomic load then store loses increments.
- Why it happens
- The combined update was not atomic.
- How to fix it
- Use fetch_add or protect the complete transition with a mutex.
- Prevent it next time
- Distinguish per-access atomicity from operation correctness.
Shutdown waits forever.
- Why it happens
- The worker does not observe cancellation or its wait is not released.
- How to fix it
- Define and implement a wakeup-aware cooperative stop protocol.
- Prevent it next time
- Test cancellation while idle and while processing.
Check your understanding
1. Can replacing items with atomic<int> alone preserve a requirement that items always equals recorded for concurrent observers? Explain the correct boundary.
Show worked solution
No. Two separately atomic updates can still be observed between their operations, and the other field would need protection too. Keep both fields under one mutex and have observers acquire that same mutex before reading the pair. The invariant belongs to the whole transition, not to either integer independently.
2. A producer writes payload, then sets ready with a relaxed store; a consumer spins on a relaxed load and reads payload. Describe a sound correction without running the racy version.
Show worked solution
Use a release store to ready and an acquire load that observes that publication, with no conflicting later payload mutation and with payload alive through the read. This supplies the required ordering for the earlier write. A mutex-protected predicate is often simpler when waiting, repeated publication, cancellation, or compound state is involved.
Key takeaways
- A data race is undefined behavior, not a bounded output variation.
- Protect complete invariants and join before final observations.
- Atomicity, publication, and cooperative shutdown need separate arguments.