Modern C++ Concurrency: A Learning Journey
Concurrency is one of the most powerful—and most misunderstood—areas of C++.
Modern C++ (C++11 → C++20) introduced a rich set of tools that allow us to write safe, expressive, and efficient concurrent programs—but only if we understand why each tool exists.
This blog is not a reference manual.
It is a learning journey: from raw threads to mutexes, from futures to async, from cooperative cancellation to coroutines.
1. Threads: The Foundation of Concurrency
What is a thread really?
A thread is an execution context managed by the operating system.
When you create a thread, you ask the OS scheduler to run a function independently of the current flow.
#include <thread>
#include <iostream>
void work() {
std::cout << "Hello from a thread\n";
}
int main() {
std::thread t(work);
t.join();
}Important truths about std::thread
- A thread starts immediately
- It does not return a value
- Exceptions do not propagate
- Forgetting
join()ordetach()causesstd::terminate()
Insight
std::thread is intentionally low-level.
It gives you power, but almost no safety.
This is why most modern C++ concurrency avoids raw threads unless necessary.
2. Shared Data and Race Conditions
The fundamental problem
Concurrency becomes hard only when data is shared.
int counter = 0;
void increment() {
counter++; // ❌ data race
}This code is undefined behavior, even if it "works" sometimes.
Why?
Because counter++ is not atomic.
It expands to:
- load
- increment
- store
Threads can interleave these steps unpredictably.
Rule
If multiple threads access the same data and at least one writes → you must synchronize.
3. Mutexes: Protecting Shared State
What a mutex really does
A mutex enforces exclusive access to data.
std::mutex m;
int counter = 0;
void increment() {
std::lock_guard<std::mutex> lock(m);
counter++;
}Why RAII matters
- Lock is acquired in constructor
- Lock is released in destructor
- No forgotten unlocks
- Exception-safe
Key design principle
Mutexes protect data, not code.
Good design asks:
- Which data does this mutex guard?
- Who is allowed to touch that data?
4. Multiple Mutexes and Deadlocks
What is a deadlock?
A deadlock occurs when:
- Thread A holds mutex X, waits for Y
- Thread B holds mutex Y, waits for X
- Both wait forever
Why deadlocks are dangerous
- Program freezes
- No crash
- Extremely hard to reproduce
- Often happens only under load
The correct solution: std::scoped_lock
std::scoped_lock lock(m1, m2);This:
- locks all mutexes atomically
- guarantees deadlock-free ordering
Rule
If you ever need to lock more than one mutex, use scoped_lock.
5. Condition Variables: Waiting Without Wasting CPU
Why mutexes alone are insufficient
Sometimes threads need to wait for something to happen, not just protect data.
Bad approach:
while (queue.empty()) {} // ❌ busy waitThis burns CPU.
Condition variables solve this
They allow a thread to:
- sleep efficiently
- release the mutex while sleeping
- wake up when notified
Consumer example
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] {
return !queue.empty();
});Why unique_lock?
wait()must unlock the mutex
- then re-lock it after wakeup
scoped_lockcannot do this
Key insight
Condition variables synchronize events, not data.
6. Atomics: Lock-Free Synchronization
When mutex is too heavy
For:
- counters
- flags
- state indicators
Using a mutex is unnecessary overhead.
std::atomic<int> counter{0};
void increment() {
counter++;
}What atomics guarantee
- No data races
- Hardware-level atomicity
- Often lock-free
What atomics cannot do
- Protect complex data structures
- Enforce multi-variable invariants
Rule
Atomics are for simple state. Mutexes are for complex state.
7. Futures and Promises: Communication Without Shared State
The core problem
Threads cannot return values.
We want:
- results
- proper synchronization
- exception propagation
Promise–Future model
promise<T>→ sets a value or exception
future<T>→ waits and retrieves it
Example
void producer(std::promise<int> p) {
p.set_value(42);
}
void consumer(std::future<int> f) {
std::cout << f.get();
}Why this is powerful
- No shared memory
- No mutex
- No condition_variable
- Exceptions propagate naturally
Mental model
Futures turn threads back into functions.
8. std::packaged_task: Functions That Produce Futures
Why it exists
Sometimes you already have a function and want:
- to execute it later
- to retrieve its result as a future
packaged_task bridges this gap.
std::packaged_task<int(int, int)> task(add);
auto fut = task.get_future();
std::thread t(std::move(task), 10, 20);Where it is used
- Thread pools
- Job schedulers
- Task queues
Insight
packaged_task is an infrastructure tool, not everyday code.
9. std::async: Asynchronous Functions Made Easy
What async really does
It combines:
- thread
- promise
- future
- exception handling
into one abstraction.
auto fut = std::async(std::launch::async, work);Why it helps
- Looks like a normal function call
- No shared state
- Automatic synchronization
- Automatic join
Important limitation
Do not use async() for lock-heavy or shared-resource code.
Use it for:
- independent computation
- pure functions
- task parallelism
10. std::jthread and Cooperative Cancellation
Why killing threads is unsafe
Threads may hold:
- mutexes
- memory
- file descriptors
- database connections
Force-stopping breaks invariants.
Cooperative cancellation (C++20)
void worker(std::stop_token tok) {
while (!tok.stop_requested()) {
// work
}
}Why this is superior
- No data races
- No forced termination
- Clean resource release
Why jthread is better than thread
- Automatically joins
- Built-in stop support
- Safer by default
11. Coroutines: Structured Asynchrony
What a coroutine is
A coroutine is a function that:
- can suspend
- can resume later
- preserves its local state
- does not block a thread
Generator counter() {
co_yield 1;
co_yield 2;
co_yield 3;
}What coroutines are NOT
- Not threads
- Not parallel execution
Why coroutines matter
- Async code looks synchronous
- No callbacks
- No state machines
- Ideal for I/O, networking, event loops
Core idea
Threads decide where code runs. Coroutines decide when code runs.