Threading & Concurrency
Two threads incrementing the same counter — one protected, one not — and why the difference is never visible in the source code alone.
Advanced
std::thread starts running as soon as it's constructed — there's no
separate "start" call. Every thread must be either joined (the creating thread
blocks until it finishes) or detached (it runs independently, and the
std::thread object stops representing it) exactly once before the
std::thread object is destroyed; skipping both calls std::terminate
on destruction.
void printMessage(const std::string &msg) { std::cout << msg << '\n'; }
std::thread t(printMessage, "hello from a thread");
t.join(); // blocks here until t finishes -- required before t is destroyed
detach() is worth treating with real suspicion rather than as a convenient
alternative to join(): a detached thread can keep running after
main() returns, or after the objects it references have already been destroyed
— there's no mechanism left to wait for it or to know when it's done. Reach for it only
when the detached work is genuinely fire-and-forget and touches nothing that could be
destroyed out from under it.
std::mutex (mutual
exclusion) is the basic tool for preventing one: only one thread can hold it locked at a
time, so wrapping every access to shared state in the same mutex's lock/unlock serializes
those accesses.
lock() and unlock() would leave the mutex locked forever.
std::lock_guard ties the lock to a stack object's lifetime instead, the same
idea as a smart pointer for a mutex:
std::mutex m;
int sharedCounter = 0;
void increment() {
std::lock_guard<std::mutex> guard(m); // locks m here
++sharedCounter;
} // guard's destructor unlocks m here -- even if this threw
| Can unlock early / relock? | Overhead | Use it when | |
|---|---|---|---|
std::lock_guard | no — locks on construction, unlocks on destruction, that's the whole interface | minimal | the default choice for a plain critical section |
std::unique_lock | yes — supports unlock()/lock() again, deferred locking, and ownership transfer | slightly more | needed by std::condition_variable::wait, or when the lock must be released before the end of scope |
std::scoped_lock (C++17) removes the need to coordinate an order at all: it locks
any number of mutexes together, atomically, using a deadlock-avoidance algorithm internally.
std::mutex mA, mB;
void transfer() {
std::scoped_lock guard(mA, mB); // locks both, in whatever order avoids deadlock with other scoped_lock calls
// ... work with both protected resources ...
} // both unlocked here
std::condition_variable is for. A waiting thread calls wait()
(which atomically unlocks its unique_lock and blocks), and a producing thread
calls notify_one()/notify_all() after changing the shared state the
waiter is checking for.
std::mutex m;
std::condition_variable cv;
bool ready = false;
void waiter() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; }); // re-checks the predicate each time it's woken, to guard against spurious wakeups
std::cout << "proceeding now that ready is true\n";
}
void setter() {
{ std::lock_guard<std::mutex> lock(m); ready = true; }
cv.notify_one();
}
wait() matters even though it looks optional —
without it, a spurious wakeup (the OS is allowed to wake a waiting thread with no
corresponding notify call) would let the thread proceed before ready
actually became true.
std::atomic<T> gives
lock-free (on most platforms, for the built-in types) safe concurrent access, without a
separate mutex to manage — ++counter on a plain int shared
across threads is a data race, but ++counter on a
std::atomic<int> is a single indivisible operation by definition.
std::atomic<int> counter{0};
void worker() {
for (int i = 0; i < 100000; ++i) ++counter; // safe -- no mutex needed for a single atomic increment
}
std::atomic only protects the single operation it wraps — "check the
flag, then act on it" across two separate atomic reads is still a race between the check and
the act, even though each individual read is atomic. Reach for a mutex once more than one
piece of state needs to change together consistently.
std::async runs a function (possibly on a new thread, possibly deferred until
you ask for the result — which one is implementation-defined unless you pass a launch
policy explicitly) and hands back a std::future representing its eventual
result. Calling .get() on the future blocks until the result is ready and
returns it — a simpler model than manually managing a thread plus a shared variable for
the return value.
std::future<int> result = std::async(std::launch::async, [] { return 6 * 7; });
// ... do other work here while it runs ...
std::cout << result.get(); // blocks until the lambda finishes, then yields 42
int a few hundred thousand times each,
compared against four threads doing the same thing through a std::mutex —
run it and the unprotected version reliably comes up short of the expected total, while the
protected one doesn't:
TopNotchNote/cpp/concurrency_counter_race_demo.cpp