Skip to content

5. Concurrency: Threads, Mutexes & Atomics

What & why

Modern CPUs have many cores; a single-threaded program uses one. C++ gives you real OS threads with no global interpreter lock — true parallelism — and also no safety net. A data race is undefined behavior. This lesson covers the tools, the rules that make them correct, and the memory model underneath.

Threads

#include <iostream>
#include <thread>
#include <vector>

void work(int id) {
    std::cout << "worker " << id << '\n';     // output may interleave!
}

int main() {
    std::vector<std::jthread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back(work, i);
    }
}   // each jthread joins automatically in its destructor

Compile with -pthread on Linux (CMake: find_package(Threads) + Threads::Threads).

std::jthread vs. std::thread

  • std::thread must be join()ed or detach()ed before destruction, or the program calls std::terminate.
  • std::jthread (C++20) joins automatically (RAII) and supports cooperative cancellation via std::stop_token.

Prefer std::jthread. Avoid detach() — a detached thread that outlives main or the data it uses is a crash waiting to happen.

Cancellation with std::stop_token

#include <chrono>
#include <thread>

std::jthread poller{[](std::stop_token st) {
    while (!st.stop_requested()) {
        poll_once();
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}};
// ...
poller.request_stop();   // also requested automatically by jthread's destructor

Passing arguments

Arguments are copied into the thread. To pass by reference, use std::ref — and make sure the referent outlives the thread:

int result = 0;
std::jthread t{[](int& out) { out = 42; }, std::ref(result)};

Lambdas capturing by reference have the same lifetime obligation.

Data races

A data race: two threads access the same memory location concurrently, at least one writes, and there's no synchronization. It's undefined behavior — not just "wrong answer".

int counter = 0;

void increment() {
    for (int i = 0; i < 100'000; ++i) {
        ++counter;          // read-modify-write: RACE
    }
}

std::jthread a{increment}, b{increment};
// after joining, counter is often less than 200000 — or anything else (UB)

Compile with -fsanitize=thread and ThreadSanitizer reports it immediately. Use TSan on all concurrent code.

Mutexes

A mutex ensures only one thread at a time runs a critical section:

#include <mutex>

std::mutex m;
int counter = 0;

void increment() {
    for (int i = 0; i < 100'000; ++i) {
        std::lock_guard lock{m};   // locks here...
        ++counter;
    }                              // ...unlocks here (RAII)
}

Never call m.lock() / m.unlock() manually — an exception or early return leaves it locked forever. Use the RAII lock types:

Type Use
std::lock_guard simplest; lock for the scope
std::scoped_lock lock several mutexes at once without deadlock
std::unique_lock movable, can unlock early/relock; required by condition variables
std::shared_lock + std::shared_mutex many readers or one writer

Encapsulate the lock with the data

Don't leave a mutex and "the data it protects" as separate globals. Bundle them:

#include <map>
#include <mutex>
#include <optional>
#include <string>

class ThreadSafeCache {
public:
    void put(const std::string& key, std::string value) {
        std::lock_guard lock{mutex_};
        data_[key] = std::move(value);
    }

    std::optional<std::string> get(const std::string& key) const {
        std::lock_guard lock{mutex_};
        if (auto it = data_.find(key); it != data_.end()) {
            return it->second;             // return a COPY, not a reference into data_
        }
        return std::nullopt;
    }

private:
    mutable std::mutex mutex_;              // mutable: lockable in const methods
    std::map<std::string, std::string> data_;
};

Never return references or pointers to protected data — the caller would use them without the lock.

Deadlock

Thread 1 locks A then B; thread 2 locks B then A → both wait forever. Defenses:

  • Lock multiple mutexes together with std::scoped_lock lock{a, b};.
  • Establish a global lock order.
  • Don't call unknown code (callbacks, virtual functions) while holding a lock.
  • Keep critical sections short.

Condition variables

Wait efficiently until some condition becomes true — the basis of producer/consumer queues:

#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>

template <typename T>
class BlockingQueue {
public:
    void push(T value) {
        {
            std::lock_guard lock{mutex_};
            queue_.push(std::move(value));
        }
        cv_.notify_one();
    }

    // Blocks until an item is available or close() is called.
    std::optional<T> pop() {
        std::unique_lock lock{mutex_};
        cv_.wait(lock, [this] { return !queue_.empty() || closed_; });   // handles spurious wakeups
        if (queue_.empty()) {
            return std::nullopt;   // closed and drained
        }
        T value = std::move(queue_.front());
        queue_.pop();
        return value;
    }

    void close() {
        {
            std::lock_guard lock{mutex_};
            closed_ = true;
        }
        cv_.notify_all();
    }

private:
    std::mutex mutex_;
    std::condition_variable cv_;
    std::queue<T> queue_;
    bool closed_ = false;
};

Always wait with a predicate — condition variables can wake spuriously.

Atomics

For single variables, std::atomic<T> provides lock-free, race-free operations:

#include <atomic>

std::atomic<int> counter{0};

void increment() {
    for (int i = 0; i < 100'000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);   // or simply ++counter
    }
}

Operations: load, store, exchange, fetch_add, fetch_sub, compare_exchange_weak/strong, plus C++20 wait/notify_one/notify_all.

Atomics make individual operations atomic, not sequences. if (x.load() == 0) x.store(1); is still a race between the check and the store — use compare_exchange:

int expected = 0;
if (x.compare_exchange_strong(expected, 1)) {
    // we changed it from 0 to 1
}

The memory model in brief

Compilers and CPUs reorder memory operations. Atomics with memory orders control what other threads are guaranteed to see:

Order Guarantees Typical use
seq_cst (default) one global order all threads agree on default; start here
acquire / release writes before a release store are visible after an acquire load that reads it publishing data, locks
relaxed atomicity only, no ordering counters, statistics

Publishing data with release/acquire:

std::string payload;
std::atomic<bool> ready{false};

// producer
payload = "hello";
ready.store(true, std::memory_order_release);

// consumer
while (!ready.load(std::memory_order_acquire)) { /* spin or wait */ }
std::cout << payload;   // guaranteed to see "hello"

Use the default seq_cst unless profiling proves it matters and you can prove the weaker order correct. Lock-free programming beyond counters and flags is expert territory.

Other tools

  • std::call_once / function-local static — thread-safe one-time initialization.
  • thread_local — one variable instance per thread.
  • std::latch — one-shot countdown: wait for N events.
  • std::barrier — reusable rendezvous point for a group of threads, per phase.
  • std::counting_semaphore — limit concurrent access to N.
  • std::atomic<std::shared_ptr<T>> (C++20) — atomically swap shared state.
  • Parallel algorithms: std::sort(std::execution::par, v.begin(), v.end()); (<execution>; on GCC requires linking TBB).

How many threads?

unsigned n = std::thread::hardware_concurrency();   // may return 0 if unknown

For CPU-bound work, about one thread per core. More threads than cores just adds context switching. For I/O-bound work, see the next lesson.

False sharing

Two threads writing to different variables that share a 64-byte cache line slow each other down dramatically:

struct Counters {
    alignas(std::hardware_destructive_interference_size) std::atomic<long> a;
    alignas(std::hardware_destructive_interference_size) std::atomic<long> b;
};

(std::hardware_destructive_interference_size is in <new>; use 64 if your standard library lacks it.)

Common mistakes

  • Data races on "just a flag" or "just a counter" — use std::atomic.
  • Manual lock()/unlock().
  • Returning references to mutex-protected data.
  • Lock ordering deadlocks; calling callbacks while holding a lock.
  • Condition variable wait without a predicate; notifying without changing state under the lock.
  • Detached threads outliving their data.
  • Capturing locals by reference in a thread lambda that outlives the scope.
  • Check-then-act on atomics without compare-exchange.
  • Not running ThreadSanitizer.

Exercises

  1. Reproduce the racy counter, run it under -fsanitize=thread, and fix it three ways: std::mutex, std::atomic<int>, and per-thread local counts summed at the end. Benchmark all three.
  2. Implement BlockingQueue<T> with a capacity limit (push blocks when full) and test it with 4 producers and 4 consumers under TSan.
  3. Parallel sum: split a std::vector<double> of 100 million elements across hardware_concurrency() std::jthreads. Measure the speedup. Then try std::reduce(std::execution::par, ...).
  4. Write a deadlock with two mutexes, observe it hang, then fix it with std::scoped_lock.
  5. Use std::latch so that N worker threads all start at the same instant after setup completes.
  6. Demonstrate false sharing: two threads incrementing adjacent atomics vs. cache-line-separated atomics. Measure.

Recap

  • std::jthread = RAII threads with cooperative cancellation (std::stop_token).
  • A data race is undefined behavior; ThreadSanitizer finds them.
  • Protect shared data with a mutex via lock_guard / scoped_lock / unique_lock; bundle the mutex with its data.
  • Condition variables wait for state changes — always with a predicate.
  • std::atomic for single variables; default seq_cst; acquire/release for publishing.
  • Watch for deadlocks, false sharing, and lifetimes of data threads reference.

Next: Async, futures & coroutines →