Concurrency
12 min read
Chapter 10: Concurrency
Concurrency is the hardest material in this book.
Not because the individual concepts are exotic — threads, locks, and condition variables are simple ideas. But because the bugs they produce are timing-dependent and non-deterministic. A data race may produce correct results in testing and wrong results in production. A deadlock may never occur on your machine and always occur on the server. You cannot fully test concurrent code the way you test sequential code.
This chapter gives you the tools to write correct concurrent C++. The emphasis is on correctness first. Performance second. Every example shows a pattern that is safe and understandable, not necessarily the fastest possible approach.
Compile concurrent code on Linux/macOS with the -pthread flag: g++ -std=c++17 -Wall -Wextra -pthread -o program source.cpp.
10.1 Threads
std::thread (from <thread>) runs a function in a separate thread of execution:
#include <iostream>
#include <thread>
#include <vector>
void worker(int id, int count) {
for (int i = 0; i < count; ++i) {
// Note: output may interleave between threads
std::cout << "Thread " << id << " step " << i << "\n";
}
}
int main() {
// Create and start threads
std::thread t1{worker, 1, 3};
std::thread t2{worker, 2, 3};
std::cout << "Main thread running\n";
// join() waits for the thread to finish
// MUST call join() or detach() before the thread object is destroyed
t1.join();
t2.join();
std::cout << "Both threads done\n";
// Threads with lambdas
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([i]() {
std::cout << "Lambda thread " << i << "\n";
});
}
for (auto& t : threads) t.join();
return 0;
}
Critical rule: every std::thread object must have either join() or detach() called before it is destroyed. If neither is called, the program calls std::terminate(). Detached threads run independently — you lose the ability to wait for them or know when they finish.
The std::jthread (C++20) automatically joins in its destructor, eliminating this footgun:
#include <thread>
#include <iostream>
int main() {
std::jthread t{[]() {
std::cout << "jthread running\n";
}};
// No need to call join() — done automatically in ~jthread()
return 0;
}
Data races
Here is the bug that gets everyone eventually:
#include <iostream>
#include <thread>
int sharedCounter = 0;
void increment(int times) {
for (int i = 0; i < times; ++i) {
++sharedCounter; // read-modify-write: NOT atomic
// Three machine instructions: load, increment, store
// Two threads can interleave these steps
}
}
int main() {
std::thread t1{increment, 100000};
std::thread t2{increment, 100000};
t1.join();
t2.join();
// Expected: 200000. Actual: less (how much less varies by run)
std::cout << "Counter: " << sharedCounter << "\n";
return 0;
}
This is a data race: two threads access the same memory location concurrently, at least one writes, and there is no synchronization. Data races are undefined behavior. The result is not just "possibly wrong" — the entire program is undefined.
10.2 Mutexes and lock_guard
A mutex (mutual exclusion) ensures only one thread executes a critical section at a time. std::lock_guard acquires the mutex on construction and releases it on destruction — RAII for locking.
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
class SafeCounter {
public:
void increment() {
std::lock_guard<std::mutex> lock{mutex_};
// Only one thread can be here at a time
++count_;
// mutex released when lock goes out of scope
}
int value() const {
std::lock_guard<std::mutex> lock{mutex_};
return count_;
}
private:
mutable std::mutex mutex_; // mutable: can be locked in const methods
int count_{0};
};
int main() {
SafeCounter counter;
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back([&counter]() {
for (int j = 0; j < 10000; ++j) {
counter.increment();
}
});
}
for (auto& t : threads) t.join();
std::cout << "Final count: " << counter.value() << "\n"; // always 100000
return 0;
}
std::unique_lock<std::mutex> is more flexible than lock_guard — it can be unlocked and relocked, and it is required for condition variables (Section 10.3). Use lock_guard when you just need a scope-locked mutex; use unique_lock when you need more control.
Deadlock is the other classic mutex bug. Thread A holds lock L1 and waits for L2. Thread B holds L2 and waits for L1. Both wait forever. Prevention strategies:
- Always acquire multiple locks in the same order in every thread.
- Use
std::lock(l1, l2)(C++11) to acquire multiple locks atomically. - Keep critical sections short to minimize lock contention.
#include <mutex>
#include <thread>
#include <iostream>
std::mutex m1, m2;
// DEADLOCK potential if two threads call this and swapArgs in different orders
void badLocking() {
std::lock_guard<std::mutex> la{m1};
std::lock_guard<std::mutex> lb{m2};
// work...
}
// SAFE: std::lock acquires both atomically, then adopt them
void safeMultipleLocks() {
std::unique_lock<std::mutex> la{m1, std::defer_lock};
std::unique_lock<std::mutex> lb{m2, std::defer_lock};
std::lock(la, lb); // acquires both — deadlock-free
// work...
}
int main() {
std::thread t1{safeMultipleLocks};
std::thread t2{safeMultipleLocks};
t1.join();
t2.join();
std::cout << "Done\n";
return 0;
}
10.3 Condition variables
A condition variable lets a thread wait until a condition becomes true, without busy-waiting (spinning in a loop consuming CPU):
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <string>
// A thread-safe message queue: producer/consumer pattern
class MessageQueue {
public:
void send(std::string msg) {
{
std::lock_guard<std::mutex> lock{mutex_};
queue_.push(std::move(msg));
}
cv_.notify_one(); // wake one waiting consumer
}
std::string receive() {
std::unique_lock<std::mutex> lock{mutex_};
// Wait releases the lock and blocks; reacquires when notified
// The predicate guards against spurious wakeups
cv_.wait(lock, [this]() { return !queue_.empty(); });
std::string msg = std::move(queue_.front());
queue_.pop();
return msg;
}
private:
std::queue<std::string> queue_;
std::mutex mutex_;
std::condition_variable cv_;
};
int main() {
MessageQueue mq;
std::thread producer{[&mq]() {
for (int i = 0; i < 5; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds{50});
mq.send("Message " + std::to_string(i));
}
mq.send("DONE");
}};
std::thread consumer{[&mq]() {
while (true) {
std::string msg = mq.receive();
std::cout << "Received: " << msg << "\n";
if (msg == "DONE") break;
}
}};
producer.join();
consumer.join();
return 0;
}
Always pass a predicate to cv.wait. Without the predicate, spurious wakeups (wakeups that occur without notify being called) can cause the consumer to run when the queue is still empty. The predicate is re-checked every time the thread wakes up — if it returns false, the thread goes back to waiting.
10.4 std::atomic
std::atomic<T> wraps a type T and guarantees that all operations on it are atomic — they cannot be interrupted by another thread mid-operation. No mutex needed for simple operations on a single value.
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>
int main() {
std::atomic<int> counter{0};
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back([&counter]() {
for (int j = 0; j < 10000; ++j) {
++counter; // atomic increment: no data race
}
});
}
for (auto& t : threads) t.join();
std::cout << "Final count: " << counter << "\n"; // always 100000
// Atomic flag for signaling between threads
std::atomic<bool> done{false};
std::thread worker{[&done]() {
std::cout << "Worker started\n";
while (!done.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds{10});
}
std::cout << "Worker stopping\n";
}};
std::this_thread::sleep_for(std::chrono::milliseconds{50});
done.store(true);
worker.join();
// Compare-and-swap: the foundation of lock-free algorithms
std::atomic<int> value{0};
int expected = 0;
bool swapped = value.compare_exchange_strong(expected, 42);
std::cout << "CAS succeeded: " << swapped << ", value: " << value.load() << "\n";
return 0;
}
When to use atomic vs mutex: use std::atomic<int> for single counters, flags, and simple values where the operation is a single read or write. Use a mutex when you need to protect a compound operation (read-then-write-based-on-read), multiple variables together, or any non-trivial invariant.
std::atomic<T> is available for integers, pointers, and (with some restrictions) trivially copyable types. For complex types, you need a mutex.
10.5 std::async and futures
std::async launches a task and returns a std::future that holds the result. The result is available when the task completes:
#include <iostream>
#include <future>
#include <vector>
#include <numeric>
#include <chrono>
#include <stdexcept>
long long sumRange(long long from, long long to) {
long long result = 0;
for (long long i = from; i <= to; ++i) result += i;
return result;
}
int main() {
const long long N = 100'000'000LL;
const long long mid = N / 2;
auto start = std::chrono::steady_clock::now();
// Launch two async tasks
auto f1 = std::async(std::launch::async, sumRange, 1LL, mid);
auto f2 = std::async(std::launch::async, sumRange, mid + 1, N);
// .get() blocks until the result is available
long long total = f1.get() + f2.get();
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
long long expected = N * (N + 1) / 2;
std::cout << "Sum 1.." << N << " = " << total << " (expected: " << expected << ")\n";
std::cout << "Time: " << ms << " ms\n";
// Exceptions from async tasks are rethrown on .get()
auto badTask = std::async(std::launch::async, []() -> int {
throw std::runtime_error{"Something went wrong in the task"};
return 42;
});
try {
int val = badTask.get(); // rethrows the exception
std::cout << "Result: " << val << "\n";
} catch (const std::runtime_error& e) {
std::cerr << "Caught from async: " << e.what() << "\n";
}
return 0;
}
std::launch::async guarantees the task runs in a new thread. Without it, the runtime may defer the task (run it on the calling thread when .get() is called). Always specify std::launch::async if you want parallelism.
Important: if you do not store the returned std::future, the task's destructor blocks until the task completes — making std::async effectively synchronous. Always capture the future.
10.6 Coroutines: a first look (C++20)
Coroutines are functions that can be suspended and resumed. They are the basis of asynchronous programming patterns in modern C++.
This is a complex topic. Here we show just enough to understand co_yield:
#include <iostream>
#include <coroutine>
// A minimal generator type — produces values on demand
template<typename T>
struct Generator {
struct promise_type {
T currentValue;
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T value) noexcept {
currentValue = value;
return {};
}
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
explicit Generator(std::coroutine_handle<promise_type> h) : handle{h} {}
~Generator() { if (handle) handle.destroy(); }
bool next() {
handle.resume();
return !handle.done();
}
T value() { return handle.promise().currentValue; }
};
// A coroutine: produces integers from 'from' to 'to'
Generator<int> range(int from, int to) {
for (int i = from; i <= to; ++i) {
co_yield i; // suspend here, give the value to the caller
}
}
int main() {
// Iterate lazily — values are produced one at a time
auto gen = range(1, 5);
while (gen.next()) {
std::cout << gen.value() << " ";
}
std::cout << "\n"; // 1 2 3 4 5
// Sum using the generator
int sum = 0;
auto gen2 = range(1, 100);
while (gen2.next()) {
sum += gen2.value();
}
std::cout << "Sum 1..100 = " << sum << "\n"; // 5050
return 0;
}
Compile with -std=c++20. GCC may also need -fcoroutines on older versions.
The key idea: co_yield suspends the coroutine, returns a value to the caller, and the coroutine resumes where it left off when asked for the next value. No threading involved — it is cooperative multitasking within a single thread.
In practice, writing the promise_type boilerplate yourself is tedious. Use a library: cppcoro (third-party) or wait for std::generator (C++23) which provides a ready-made generator type.
Coroutines are the foundation for the async/await patterns that make asynchronous I/O tractable. We will not go deeper into them here — full coverage would require a chapter of its own.
10.7 Exercises
Easy
-
Launch 4 threads, each printing its ID 5 times. Observe that the output interleaves. Then wrap the print statement in a mutex lock and observe the output becomes clean (though the order of IDs may vary).
-
Use
std::asyncto compute the squares of 1 through 10 in parallel (one async per number), collect the futures, and print the results in order.
Medium
- Implement a thread-safe
BoundedQueue<T>with a maximum capacity.pushshould block when the queue is full;popshould block when the queue is empty. Usestd::mutexandstd::condition_variable.
Stretch
- Implement a parallel
mapfunction:parallelMap(const std::vector<T>& input, F f, int nThreads)that divides the input intonThreadschunks, processes each chunk in a separate thread, and returns astd::vector<R>with the results in order. Usestd::thread::hardware_concurrency()as the default thread count.
10.8 Common mistakes
-
Forgetting to call
join()ordetach(). If astd::threadobject is destroyed without either,std::terminate()is called. Usestd::jthread(C++20) to get automatic joining. -
Data races. Accessing shared data without synchronization is undefined behavior. The program may appear to work in testing and fail in production under higher load. Use
-fsanitize=thread(ThreadSanitizer) to detect races. -
Deadlock. Two threads each waiting for a lock the other holds. Prevention: always acquire locks in the same order, or use
std::lock()for multiple locks. -
Holding locks too long. Keep critical sections small. Long-held locks serialize your program, eliminating the benefit of threading.
-
Discarding the future from
std::async.auto _ = std::async(std::launch::async, task);— the future's destructor blocks, making it synchronous. Always store the future. -
Spurious wakeup without a predicate in
condition_variable::wait. Always pass the predicate:cv.wait(lock, [&]() { return !queue.empty(); }). -
Locking a non-recursive mutex from the same thread twice.
std::mutexis not recursive — locking it twice from the same thread is instant deadlock. Usestd::recursive_mutexif you genuinely need that, or redesign. -
Using
std::atomicfor compound operations.if (counter == 0) counter = initialValue;is not atomic even ifcounteris atomic. The check and the assignment are two separate operations — usecompare_exchange_strongor a mutex.
10.9 Summary
Concurrency lets multiple computations happen simultaneously, but introduces race conditions, deadlocks, and non-determinism.
std::thread creates a new thread of execution. Always call join() or detach(). std::jthread (C++20) joins automatically.
Data races are undefined behavior. Protect shared data with std::mutex and std::lock_guard/std::unique_lock. Keep critical sections short.
Condition variables let threads wait for conditions without busy-waiting. Always pass a predicate to wait().
std::atomic<T> provides lock-free operations on single values. Use for counters and flags; use mutexes for complex invariants.
std::async launches tasks and returns std::futures for collecting results. Exceptions from async tasks are rethrown on .get().
Coroutines (C++20) enable cooperative multitasking and lazy computation with co_yield and co_await.
In Chapter 11 we collect the modern C++ idioms and best practices that do not fit neatly into a single topic — const-correctness, attributes, compile-time computation, and more.
All code for this chapter is in the GitHub repository:
<TODO: book code repository URL>/tree/main/chapter10