Concurrency means managing overlapping activities. Parallelism means executing work simultaneously. A program can be concurrent on one processor, and adding threads does not guarantee that a calculation becomes faster. Thread creation, scheduling, synchronization and memory traffic all have costs.
The hardest question is usually who may access shared state. A data race occurs when conflicting accesses from different threads, with at least one write and at least one non-atomic access, are not ordered by the required synchronization. A data race gives a C++ program undefined behavior. It is more serious than occasionally obtaining an incorrect count.
Keep thread lifetimes scoped
C++20's std::jthread owns a thread of execution. If joinable when destroyed, it requests cancellation and joins, waiting for completion. By contrast, destroying a joinable std::thread calls std::terminate. Scoped joining is valuable when an early return or exception could otherwise bypass a manual join.
Save as threads.cpp:
#include <exception>
#include <iostream>
#include <mutex>
#include <thread>
class Counter {
public:
void increment() {
const std::lock_guard<std::mutex> lock{mutex_};
++value_;
}
int value() const {
const std::lock_guard<std::mutex> lock{mutex_};
return value_;
}
private:
mutable std::mutex mutex_;
int value_{};
};
int main() {
try {
Counter counter;
const auto work = [&counter] {
for (int step = 0; step < 1000; ++step) {
counter.increment();
}
};
{
std::jthread first{work};
std::jthread second{work};
}
std::cout << "Completed: " << counter.value() << '\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
clang++ -std=c++20 -Wall -Wextra -Wpedantic -g -pthread threads.cpp -o threads
./threads
The output is always Completed: 2000 on a successful run. -pthread is the compiler-driver threading option on the GCC/Clang platforms used by this command; other toolchains have their own build configuration. Check both compiler and standard-library C++20 support if jthread is unavailable.
The inner scope destroys and joins both workers before the result is read. The counter is declared outside that scope, so the workers' borrowed reference remains valid. The maximum count is two thousand and fits even the minimum required range of int.
Protect the data and the invariant
A mutex permits one lock owner at a time. The named lock guard acquires the mutex on construction and releases it on destruction. Every access to value_ uses the same mutex, including reads. The mutex is mutable so a const observer can synchronize without changing the logical counter value.
++value_ is a read-modify-write operation; being one source expression does not make it atomic. volatile does not make it atomic either and does not provide inter-thread synchronization. A std::atomic<int> can support a simple atomic increment, but separate atomic fields do not automatically preserve a multi-field invariant. Start with default atomic ordering until you can justify a weaker ordering precisely.
A join synchronizes completion with the joining thread. A mutex unlock and a subsequent successful acquisition establish ordering for the protected data. These relationships matter more than guessed delays. Sleeping for a millisecond is not proof that another thread has finished.
Cancellation, waiting and failure
A stop request is cooperative. A worker can accept std::stop_token and check stop_requested(), but the request does not forcibly interrupt arbitrary I/O. These finite workers ignore stop requests and complete their loops, so destruction waits for them. Avoid detaching threads whose borrowed data might disappear.
An exception escaping a worker function terminates the process; the handler in main does not catch it across the thread boundary. Production workers need an explicit reporting path, such as catching inside the worker and transporting an exception_ptr, or using a promise and future.
For producer-consumer work, a condition variable waits for a state predicate protected by a mutex. Use its predicate overload because wakeups can occur without the condition becoming true. Avoid deadlock by keeping lock scopes short, not calling unknown callbacks while locked, and using std::scoped_lock when acquiring multiple mutexes together.
Practice and solution guidance
- Give each worker a local subtotal and merge once. Guidance: fewer lock acquisitions can reduce contention; compare results before measuring time.
- Add cooperative stopping to a long loop. Guidance: accept a stop token, check it regularly and describe which work may remain incomplete.
- Run a separate race-detection build on a supported installation:
clang++ -std=c++20 -O1 -g -pthread -fsanitize=thread threads.cpp -o threads-tsan
./threads-tsan
Guidance: a clean run is useful evidence, not a proof for every schedule. Keep this build separate from AddressSanitizer.

