Multithreading in C++

std::thread, data races, std::mutex and std::lock_guard, and a complete thread-safe bank account example.

Why multithreading

A single thread runs one instruction stream at a time — even on a machine with eight CPU cores, a single-threaded program can only ever use one of them. Multithreading lets a program run several independent streams of execution concurrently, genuinely using multiple cores in parallel for CPU-bound work, or overlapping I/O waits with other useful computation. C++11 added std::thread and a set of synchronization primitives directly to the standard library, replacing what used to require platform-specific APIs (POSIX threads on Linux/macOS, the Windows threading API) with one portable, standard interface.

std::thread basics

C++
#include <iostream>
#include <thread>

void printNumbers() {
    for (int i = 1; i <= 5; i++) {
        std::cout << "Number: " << i << "\n";
    }
}

int main() {
    std::thread worker(printNumbers); // starts running printNumbers CONCURRENTLY, immediately

    std::cout << "Main thread continues...\n"; // may print before, after, or interleaved with worker's output

    worker.join(); // blocks main until 'worker' finishes — REQUIRED before worker goes out of scope

    std::cout << "Worker finished\n";
}

Constructing a std::thread starts it running immediately, concurrently with the thread that created it — there's no separate "start" call. join() blocks the calling thread until the target thread finishes; every std::thread that's actually been started must have either join() or detach() called on it before it's destroyed, or the program terminates immediately via std::terminate() — there's no implicit fallback to either behavior.

Passing arguments to a thread's function works positionally, exactly like calling the function directly:

C++
void greet(const std::string& name, int times) {
    for (int i = 0; i < times; i++) {
        std::cout << "Hello, " << name << "!\n";
    }
}

std::thread t(greet, "Ada", 3); // arguments after the function are forwarded to it
t.join();

Data races: why concurrent access is dangerous

A data race happens when two threads access the same memory location at the same time, at least one of them writing, with no synchronization between them — the result is undefined behavior, not just "maybe the wrong number." Here's a genuinely broken counter, incremented concurrently by two threads with no protection at all:

C++
#include <iostream>
#include <thread>

int counter = 0; // shared, unprotected state

void incrementMany() {
    for (int i = 0; i < 100000; i++) {
        counter++; // NOT atomic — read, increment, write are three separate steps
    }
}

int main() {
    std::thread t1(incrementMany);
    std::thread t2(incrementMany);

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << "\n"; // expected 200000, but often LESS — a race condition
}

counter++ looks like one operation, but at the machine level it's really "read counter," "add one," "write it back" — three separate steps. If both threads read the same value before either writes back its increment, one increment is silently lost. Run this program repeatedly and you'll typically see a different, wrong number every time — a telltale sign of a genuine race condition rather than a deterministic logic bug.

std::mutex and std::lock_guard

A mutex ("mutual exclusion") lets only one thread execute a protected section of code at a time — any other thread trying to acquire the same mutex simply blocks until the first one releases it. std::lock_guard is the idiomatic RAII wrapper: it acquires the mutex when constructed and automatically releases it when it goes out of scope, even if an exception is thrown in between — the same RAII pattern used for memory management (covered on that page) applied to a lock instead of a heap allocation.

C++
#include <iostream>
#include <mutex>
#include <thread>

int counter = 0;
std::mutex counterMutex; // protects 'counter'

void incrementMany() {
    for (int i = 0; i < 100000; i++) {
        std::lock_guard<std::mutex> lock(counterMutex); // acquired here
        counter++;                                        // only one thread inside this block at a time
    }                                                       // released automatically here, even on exception
}

int main() {
    std::thread t1(incrementMany);
    std::thread t2(incrementMany);

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << "\n"; // always exactly 200000 — the race is fixed
}

Locking around every single counter++ call individually (rather than the whole loop) is deliberate here — it keeps the "critical section" (the code actually holding the lock) as small as possible, since anything inside it blocks other threads from making progress.

Unprotected int std::mutex + std::lock_guard
Concurrent increment result Undefined — usually wrong, and different each run Always correct
Overhead None Small — lock/unlock cost per critical section
Failure mode Silent (wrong number, no crash, no warning) N/A — correctness is guaranteed by the lock

A complete example: a thread-safe bank account

C++
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

class BankAccount {
public:
    void deposit(double amount) {
        std::lock_guard<std::mutex> lock(mutex_);
        balance_ += amount;
    }

    void withdraw(double amount) {
        std::lock_guard<std::mutex> lock(mutex_);
        if (amount > balance_) {
            return; // insufficient funds — protected the same way as a normal withdrawal
        }
        balance_ -= amount;
    }

    double balance() const {
        std::lock_guard<std::mutex> lock(mutex_);
        return balance_;
    }

private:
    mutable std::mutex mutex_; // 'mutable' so balance() can lock even though it's a const method
    double balance_ = 0.0;
};

int main() {
    BankAccount account;
    account.deposit(1000.0);

    std::vector<std::thread> threads;
    for (int i = 0; i < 10; i++) {
        threads.emplace_back([&account]() { // each thread withdraws 50, ten times over
            for (int j = 0; j < 10; j++) {
                account.withdraw(50.0);
            }
        });
    }

    for (auto& t : threads) {
        t.join(); // wait for every thread to finish before checking the final balance
    }

    std::cout << "Final balance: " << account.balance() << "\n"; // 0 — every withdrawal was correctly serialized
}

Every method that touches balance_ takes the same lock, which is what makes the class genuinely thread-safe as a whole — protecting only some of the methods that touch shared state (a common real-world mistake) would leave the class only partially safe, and partial thread-safety is effectively no thread-safety at all.

Common mistakes

  • Forgetting to join() (or detach()) a std::thread before it's destroyed — the program calls std::terminate() immediately, crashing the whole process.
  • Accessing shared state from multiple threads with no mutex (or other synchronization) at all — the classic data race, which often "happens to work" in casual testing and then fails unpredictably in production under real concurrent load.
  • Holding a lock for far longer than necessary (locking around an entire function instead of just the few lines that touch shared state), which serializes threads more than needed and defeats much of the benefit of using multiple threads in the first place.
  • Locking two mutexes in inconsistent order across different threads — thread A locks mutexX then waits for mutexY, while thread B locks mutexY then waits for mutexX — producing a deadlock where neither can ever proceed.

Interview questions

Q: What is a data race, and why is counter++ on a plain shared int from two threads not safe, even though it looks like one operation? A data race occurs when two threads access the same memory location concurrently with no synchronization, at least one of them writing — the C++ standard treats this as undefined behavior, not merely "an unpredictable but bounded result." counter++ compiles to separate read, increment, and write steps at the machine level, so two threads can both read the same old value before either writes back its increment, silently losing one of the increments — the observable symptom is a final count lower than expected, and it's often non-reproducible from run to run.

Q: What does std::lock_guard do, and why is it preferred over calling a mutex's lock()/unlock() manually? std::lock_guard acquires a mutex when it's constructed and releases it automatically in its destructor when it goes out of scope — including when an exception is thrown partway through the protected code, which is exactly the RAII pattern used elsewhere in C++ for resource cleanup. Calling lock()/unlock() manually requires getting every exit path right by hand, including exception paths, and a single forgotten unlock() (easy to miss on an early return or thrown exception) leaves the mutex permanently locked, deadlocking every other thread that later tries to acquire it.