Skip to content

03 · Concurrency

Every program so far has run one instruction at a time. std::thread lets a single process run multiple independent lines of execution at once — useful for keeping a UI responsive while work happens in the background, or for splitting a CPU-heavy computation across cores. The catch: as soon as two threads touch the same memory, you need to actively prevent them from tripping over each other. This module covers std::thread, std::mutex, and the tools built around them. Module 10 turns all of it into a working task processor.

Starting and joining a thread

#include <iostream>
#include <thread>

void greet(const std::string& name) {
    std::cout << "Hello from " << name << std::endl;
}

int main() {
    std::thread t(greet, "worker-1");   // starts running greet() concurrently, immediately

    std::cout << "Main thread continues..." << std::endl;

    t.join();    // block here until t finishes -- REQUIRED before t is destroyed
    std::cout << "Worker finished." << std::endl;
}
// Main thread continues...
// Hello from worker-1
// Worker finished.

The first two lines can print in either ordermain and t are racing. That's expected and fine here because they don't share any data. What you must never skip is join() (wait for it to finish) or detach() (explicitly let it run fully independently, forever, unsupervised). A std::thread that's still joinable when its destructor runs calls std::terminate() and kills the whole program — no exception, no diagnostic, just an abrupt crash. This is the RAII lesson from Module 4 applied to threads: every std::thread needs exactly one of join()/detach() before it goes out of scope.

The race condition

#include <iostream>
#include <thread>

int counter = 0;

void incrementMany() {
    for (int i = 0; i < 100000; ++i) {
        counter++;    // NOT atomic: read, add 1, write back -- three separate steps
    }
}

int main() {
    std::thread t1(incrementMany);
    std::thread t2(incrementMany);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << std::endl;
}
// counter = 137482          <- expected 200000. The exact number varies every run.

counter++ is not one CPU instruction — it's read, increment, write. If thread A reads counter as 5, then thread B also reads it as 5 before A writes back 6, both threads compute 6 and one increment is silently lost. This is a data race: undefined behavior in the C++ standard, not just "a wrong number" — the compiler is permitted to assume it never happens and optimize accordingly, so the actual failure mode can be stranger than a miscount.

std::mutex — mutual exclusion

A mutex guarantees only one thread executes a given section of code at a time.

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex counterMutex;

void incrementMany() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(counterMutex);   // locks on construction
        counter++;
    }   // lock released automatically here -- RAII, same idiom as smart pointers
}

int main() {
    std::thread t1(incrementMany);
    std::thread t2(incrementMany);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << std::endl;   // always 200000, every run
}

std::lock_guard is deliberately minimal: it locks in its constructor and unlocks in its destructor, and that's it. Because it's RAII, the mutex is released correctly even if an exception is thrown inside the locked section — there is no try/finally needed, and no path where you forget to unlock. Never call mutex.lock()/mutex.unlock() by hand in ordinary code; a thrown exception between them leaves the mutex locked forever, deadlocking every other thread that waits on it.

std::unique_lock — when you need more control

std::lock_guard can't unlock early or be used with condition variables. std::unique_lock is a more flexible RAII wrapper for those cases:

#include <mutex>

std::mutex m;

void example() {
    std::unique_lock<std::mutex> lock(m);
    // ... critical section ...
    lock.unlock();          // manually release early, still exception-safe overall
    // ... non-critical work, other threads can now enter ...
    lock.lock();             // re-acquire if needed
}   // destructor unlocks again if still locked -- safe either way

Prefer lock_guard by default — it's slightly cheaper and communicates "this lock's lifetime is exactly this scope." Reach for unique_lock only when you need to unlock early or hand the lock to a std::condition_variable.

Deadlock

#include <mutex>
#include <thread>

std::mutex mA, mB;

void taskOne() {
    std::lock_guard<std::mutex> lockA(mA);
    std::lock_guard<std::mutex> lockB(mB);   // if taskTwo holds mB and wants mA now...
}

void taskTwo() {
    std::lock_guard<std::mutex> lockB(mB);
    std::lock_guard<std::mutex> lockA(mA);   // ...both threads wait forever. Deadlock.
}

Two threads, each holding one lock while waiting for the other's — neither can proceed. The fix: always acquire multiple mutexes in a fixed, global order, or lock them together atomically:

#include <mutex>

void taskOne() {
    std::scoped_lock lock(mA, mB);   // C++17: locks both, deadlock-free, in any order
}

void taskTwo() {
    std::scoped_lock lock(mA, mB);   // same order argument -- std::scoped_lock sorts this out internally
}

std::scoped_lock (C++17) takes any number of mutexes and uses a deadlock-avoidance algorithm internally, so callers don't need to agree on an ordering by convention — prefer it whenever more than one mutex must be held at once.

std::atomic — lock-free for simple types

For a single counter, a full mutex is overkill. std::atomic<T> makes individual operations indivisible in hardware, without an explicit lock.

#include <iostream>
#include <thread>
#include <atomic>

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

void incrementMany() {
    for (int i = 0; i < 100000; ++i) {
        counter++;    // atomic increment -- a single indivisible hardware operation
    }
}

int main() {
    std::thread t1(incrementMany);
    std::thread t2(incrementMany);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << std::endl;   // always 200000
}

std::atomic only covers single operations on one variable (++, +=, compare-and-swap). The moment correctness depends on multiple variables staying consistent with each other, you need a mutex to protect the whole group of them — atomics don't compose.

std::condition_variable — waiting for a signal

A thread often needs to sleep until another thread says "there's work now," rather than repeatedly checking (a wasteful "busy loop").

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> jobs;
bool done = false;

void worker() {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        // Sleep until the predicate is true; avoids "spurious wakeup" bugs
        // and re-checks the condition automatically after each wake.
        cv.wait(lock, [] { return !jobs.empty() || done; });

        if (!jobs.empty()) {
            int job = jobs.front();
            jobs.pop();
            lock.unlock();
            std::cout << "processing job " << job << std::endl;
        } else if (done) {
            break;
        }
    }
}

int main() {
    std::thread w(worker);

    for (int i = 1; i <= 3; ++i) {
        { std::lock_guard<std::mutex> lock(mtx); jobs.push(i); }
        cv.notify_one();     // wake the worker: there's a job waiting
    }

    { std::lock_guard<std::mutex> lock(mtx); done = true; }
    cv.notify_one();
    w.join();
}
// processing job 1
// processing job 2
// processing job 3

cv.wait(lock, predicate) atomically releases the lock while sleeping (so the producer can acquire it to push work) and re-acquires it before checking the predicate again on wake. This exact pattern — mutex + condition_variable + shared queue — is the backbone of Module 10's thread pool.

std::async and std::future — a result, not a thread

When you just want "run this and give me the return value later," std::async is simpler than managing a std::thread and a shared variable by hand:

#include <iostream>
#include <future>

int computeSquare(int x) { return x * x; }

int main() {
    std::future<int> result = std::async(std::launch::async, computeSquare, 12);

    std::cout << "doing other work..." << std::endl;

    std::cout << "square = " << result.get() << std::endl;   // blocks until ready
}
// doing other work...
// square = 144

result.get() blocks until the async task finishes and returns its value — or rethrows an exception if the task threw one, propagating it across the thread boundary automatically, which manual std::thread doesn't do for you.

Cheat sheet

Tool Purpose
std::thread Start a concurrent function; must join() or detach() before destruction
std::mutex Mutual exclusion — one thread in the critical section at a time
std::lock_guard RAII lock, fixed scope, cheapest — the default choice
std::unique_lock RAII lock, can unlock early or pair with a condition variable
std::scoped_lock RAII lock over multiple mutexes at once, deadlock-free
std::atomic<T> Lock-free indivisible ops on one variable
std::condition_variable Sleep until another thread signals a condition, no busy-waiting
std::async / std::future Run a function concurrently and collect its return value (or exception) later

Traps

A data race is undefined behavior, not just "probably fine." Even a variable that "usually" reads correctly across threads without a mutex or atomic is a bug that can manifest differently under a different compiler, optimization level, or CPU — never rely on it happening to work.

join() must run exactly once, and calling it twice throws std::system_error. If a thread might already have been joined, check t.joinable() first.

Forgetting to unlock manually-managed mutexes on an exception path is why lock_guard/unique_lock exist — never call .lock()/.unlock() directly in code that can throw between them.

How It Actually Works

std::thread is a thin wrapper around the operating system's native thread creation call (pthread_create on POSIX systems, CreateThread on Windows). Each thread gets its own stack (a separate region of memory for local variables and call frames, typically a few MB, allocated by the OS at creation) but shares the same heap and global/static memory with every other thread in the process — that shared mutability is the entire source of data races: two threads incrementing the same int isn't atomic at the machine level (it's actually read-modify-write: load the value into a register, add one, store it back), so if both threads' loads happen before either's store, one increment is silently lost. A data race on a non-atomic variable is undefined behavior in the C++ standard, not merely "the wrong answer" — the compiler is permitted to assume no race exists and optimize accordingly, which can produce results even stranger than a lost update.

std::mutex works by asking the OS kernel to arbitrate: lock() either succeeds immediately (if uncontended, often via a fast userspace atomic compare-and-swap with no kernel involvement at all) or, if another thread holds it, puts the calling thread to sleep (removed from the CPU's run queue entirely, at kernel level) until the mutex is released and the kernel wakes it back up — meaningfully different from spinning in a loop, which would burn CPU cycles instead. std::lock_guard/std::unique_lock are RAII wrappers (Module 4) around lock()/unlock(): the guard's destructor calls unlock(), which is what makes a mutex get released even if an exception is thrown while the lock is held — the same stack-unwinding mechanism from Level 1's exception module runs the guard's destructor during unwinding.

std::atomic<T> operations compile to actual hardware-level atomic instructions (like lock xadd on x86) rather than a mutex — the CPU guarantees the read-modify-write happens as one indivisible step relative to every other core, which is why atomics avoid the OS-level sleep/wake cost of a mutex for simple counters, at the price of only working for operations the hardware directly supports.

Exercise

Build a thread-safe counter class SafeCounter wrapping an int and a std::mutex, with increment() and int value() const methods (the latter also needs to lock — reading while another thread writes is still a race). Launch four threads, each calling increment() 50,000 times, join() them all, and print the final value — confirm it's always exactly 200,000 across several runs. Then replace the int + mutex with a single std::atomic<int> and confirm the behaviour is identical but the code is shorter.