Back to all articlesC++

Modern C++ Concurrency — From Threads to Coroutines

12 min read

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() or detach() causes std::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:

  1. load
  1. increment
  1. 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 wait

This 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_lock cannot 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.