C++ Concurrency: Threads, Mutexes, Futures and Atomics with Worked Examples

Learn what each C++ concurrency tool actually guarantees. Trace a lost update, repair it with a mutex and an atomic, and combine future results step by step.

KnowledgeGate Team

Exam prep & CS education

Updated 27 Oct 20265 min read

Creating a std::thread is easy. The hard part begins when two threads touch the same state without a clear ownership or synchronisation rule, because code that looks reasonable can become unreliable. An exact lost update starts from counter = 41; both a mutex and an atomic make the intended result 43 reliable, and two futures calculate the sum of squares from 1 through 6 as 91. Choose each tool according to the job instead of treating them as interchangeable features.

C++ concurrency: the four tools solve different problems

Concurrency is overlapping progress by multiple tasks. Parallelism is work executing simultaneously. A one-core program can be concurrent through interleaving, while parallel execution depends on the machine and scheduler. Never assume an order unless the program establishes it.

Tool

Main job

std::thread

Run a callable

std::mutex

Give one thread exclusive access to a critical section

std::future<T>

Represent a result that may arrive later

std::atomic<T>

Provide indivisible operations on one supported object

None makes a whole algorithm correct. A data race occurs when two threads perform conflicting operations on the same memory location, at least one is a write, and synchronisation does not order them. In C++, that is undefined behaviour, not another valid answer. Review the C++ Tutorial if lambdas, references or callables are unfamiliar.

std::thread: launch work, pass arguments and join before reading

Consider two independent multiplications:

cpp
void multiply(int x, int factor, int& out) {
    out = x * factor;
}

int a = 0;
int b = 0;
std::thread t1(multiply, 7, 6, std::ref(a));
std::thread t2(multiply, 5, 9, std::ref(b));
t1.join();
t2.join();
std::cout << a + b;

t1 writes a = 7 * 6 = 42, while t2 writes b = 5 * 9 = 45. After both join() calls, the main thread prints 42 + 45 = 87. Either may run first; the answer is unchanged because they write different objects.

Thread arguments are stored as decayed values, so std::ref is required here. Joining makes later reads safe. Destroying a joinable thread before join() or detach() calls std::terminate. Prefer owned arguments and joining because detached work can outlive referenced locals.

C++ race condition: an exact lost-update trace

Start with int counter = 41;. Two unsynchronised ++counter operations intend 41 + 1 + 1 = 43, but an illustrative read-modify-write interleaving is:

  1. T1 reads 41, then T2 reads 41.

  2. T1 computes 42, then T2 computes 42.

  3. T1 writes 42, then T2 writes 42.

The trace ends at 42, losing an increment. The actual C++ program has a data race and undefined behaviour, so 42 illustrates the hazard rather than guaranteeing an output. A race condition is timing dependence, a data race is the memory-model violation, and a critical section is protected code that must not overlap.

Two-lane timeline of a lost update: T1 and T2 both read 41 and write 42, while the intended result is 43 and the program has undefined behaviour.

std::mutex: protect a multi-step critical section with RAII

A mutex repairs the counter by allowing only one thread into the update:

cpp
std::mutex m;
int counter = 41;

auto increment = [&] {
    std::lock_guard<std::mutex> lock(m);
    ++counter;
};

std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter;

If T1 enters first, the values are 41 -> 42 -> 43. If T2 enters first, they are still 41 -> 42 -> 43. After both joins, the guaranteed output is 43.

std::lock_guard locks on construction and unlocks on destruction, even on early exit. Keep unrelated work outside the critical section. Opposite acquisition orders can deadlock. Use a consistent order or std::scoped_lock lock(m1, m2);. The wider model appears in Process Synchronization and Semaphores.

std::future and std::async: return values from concurrent work

A future lets each task deliver a value:

cpp
long long sum_squares(int first, int last) {
    long long total = 0;
    for (int x = first; x <= last; ++x) {
        total += 1LL * x * x;
    }
    return total;
}

auto left = std::async(std::launch::async, sum_squares, 1, 3);
auto right = std::async(std::launch::async, sum_squares, 4, 6);
long long total = left.get() + right.get();

The left result is 1^2 + 2^2 + 3^2 = 1 + 4 + 9 = 14. The right result is 4^2 + 5^2 + 6^2 = 16 + 25 + 36 = 77. Therefore, total = 14 + 77 = 91.

Explicit std::launch::async matters because the default policy may defer execution. get() waits, returns the value, and rethrows a captured exception. A future has single consumption, so a second get() is invalid. It transfers one result; mutexes and atomics coordinate shared state.

A fork-and-join data-flow figure with the main thread launching Task L for integers 1,2,3 and Task R for 4,5,6. Task L box shows 1+4+9=14, Task R box shows 16+25+36=77, and two future arrows join at left.get()+right.get()=14+77=91.

std::atomic: indivisible operations on one shared value

The counter can use one atomic operation per increment:

cpp
std::atomic<int> counter{41};

auto increment = [&] {
    counter.fetch_add(1);
};

std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter.load();

Each fetch_add(1) is an atomic read-modify-write. Whichever thread goes first, the modification order is 41 -> 42 -> 43, and the value after both joins is 43.

Atomics suit one counter, flag or pointer, but do not combine objects into a transaction. If balance = 900 and transaction_count = 4 must become balance = 850 and transaction_count = 5 together, protect the pair with one mutex. This code uses sequential consistency. Consider memory_order_relaxed only when a counter guards no other data. Not every std::atomic<T> is lock-free.

C++ concurrency traps and representative test patterns

Trap

What goes wrong

Repair

Destroying a joinable thread

Calls std::terminate

Join or use scoped ownership

Capturing a local by reference in longer-lived work

Reference dangles

Join in scope or move data

Calling future.get() twice

Future is single-use

Store the first result

Omitting std::launch::async while assuming a worker

May defer execution

State the launch policy

Treating volatile as synchronisation

The data race remains

Use a mutex or atomic

Updating two atomics as one invariant

A partial change is observable

Lock the invariant

Representative tasks trace joined code, find a data race, choose lock_guard, compute a future, distinguish atomic increment from separate load and store, or spot opposite lock orders. Never demand an exact output from code with a data race.

Situation

Tool

Independent task with no return value

std::thread

Shared multi-step invariant

std::mutex

Asynchronous value or exception

std::future

Single shared counter or flag

Consider std::atomic, after checking it is one-object state

C++ concurrency: the short version and next step

Keep this recall card:

  • Threads run callables, and you join before reading their results.

  • A data race is undefined behaviour.

  • Mutexes protect critical sections and multi-object invariants.

  • Futures carry eventual values and exceptions.

  • Atomics provide indivisible operations on one shared object. They are not automatically faster.

Reproduce the checkpoints without looking: 42 and 45 make 87; two safe increments move 41 to 43; 14 and 77 make 91. Then explain why the lost-update trace is illustrative, not a promised output.

For broader foundations and practice, continue with C++ Programming: Concepts, MCQs, Coding Questions. For a wider programming, DSA and placement route, explore Coding & DSA Courses for Placements. Choose from the ownership pattern, then code.