C++ Multithreading

C++ multithreading lets a single program run multiple sequences of instructions, called threads, at the same time. Instead of doing one task after another, your program can compute, read data, and respond to work concurrently, taking full advantage of modern multi-core processors. Since C++11, the standard library ships a portable threading toolkit — <thread>, <mutex>, <atomic>, and <future> — so you no longer need platform-specific APIs like pthreads or the Windows thread API. Multithreading is powerful but dangerous: shared data touched by more than one thread without protection leads to race conditions, one of the hardest classes of bugs to find and fix.

Overview: How Multithreading Works

A running program is a process. A process owns its own memory address space: a heap, global and static variables, and open file handles. A thread is a unit of execution inside a process. Every process starts with one thread (the one that runs main), but a process can spawn additional threads that all share the same heap and globals. The key difference between threads is that each thread gets its own call stack and its own CPU register state (including the program counter), so each thread can be paused, resumed, and scheduled independently while still reading and writing the same shared data as its siblings.

The operating system’s scheduler decides which thread runs on which CPU core and for how long. On a machine with multiple cores, threads can genuinely execute at the same instant — this is parallelism. On a single core, the OS rapidly switches between threads (a context switch), giving the illusion of simultaneous progress — this is concurrency. C++’s std::thread is a thin, portable wrapper around whatever native threading facility the OS provides (pthreads on Linux/macOS, Win32 threads on Windows).

Because sibling threads share memory, two threads reading and writing the same variable at the same time — with at least one of them writing, and no synchronization — is a data race. In C++ this is undefined behavior: the compiler is free to reorder instructions and cache values in registers in ways that make the outcome unpredictable, not merely “probably fine but slow.” To coordinate threads safely, C++ provides several tools: a std::mutex (mutual exclusion lock) that lets only one thread execute a protected section of code at a time; std::atomic<T>, which performs simple read-modify-write operations as one indivisible hardware instruction; and std::condition_variable, which lets threads sleep until another thread signals that some condition has become true (useful for producer/consumer pipelines).

Creating a thread is not free — the OS must allocate a stack (often megabytes, reserved lazily) and register bookkeeping with the kernel. For short-lived or very numerous tasks, real-world code typically uses a thread pool instead of spawning a fresh std::thread per task, though the standard library does not (yet) ship one built in.

Syntax

The general form for creating and waiting on a thread is:

#include <thread>

std::thread t(callable, arg1, arg2 /* ... */);
t.join();     // block until t finishes, OR
t.detach();   // let t run independently in the background
Piece Meaning
callable A function pointer, lambda, or function object (functor) that the new thread will run.
arg1, arg2, ... Arguments forwarded to the callable. They are copied/moved into internal storage by default; wrap an argument in std::ref(x) to pass it by reference.
t.join() Blocks the calling thread until t finishes. Every joinable thread must be joined or detached before its std::thread object is destroyed.
t.detach() Disconnects the std::thread object from the running thread. The thread keeps running in the background; you can no longer join or observe it.
t.joinable() Returns true if t represents an active thread of execution that hasn’t been joined or detached yet.

Protecting shared data uses a mutex together with an RAII lock so the lock is always released, even if an exception is thrown:

#include <mutex>

std::mutex m;
{
    std::lock_guard<std::mutex> lock(m);
    // critical section: only one thread runs this at a time
}   // lock is released automatically here
Tool Header Use it for
std::mutex <mutex> A lock that only one thread can hold at a time.
std::lock_guard <mutex> Simplest RAII lock: locks on construction, unlocks on destruction.
std::unique_lock <mutex> Like lock_guard but movable and unlockable early; required by std::condition_variable.
std::atomic<T> <atomic> Lock-free, indivisible operations on a single value (counters, flags).
std::async / std::future <future> Run a function that returns a value on another thread and collect the result later.
std::condition_variable <condition_variable> Let a thread sleep until another thread signals a condition changed.

Important: on Linux, programs using <thread> must be linked against the pthreads library, so compile with g++ -std=c++17 -pthread file.cpp. Forgetting -pthread is a common source of confusing linker errors.

Examples

Example 1: Creating and joining threads

#include <iostream>
#include <thread>

void computeSquare(int n, int& result) {
    result = n * n;
}

int main() {
    int result1 = 0, result2 = 0;

    std::thread t1(computeSquare, 5, std::ref(result1));
    std::thread t2(computeSquare, 8, std::ref(result2));

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

    std::cout << "Square of 5 is " << result1 << "\n";
    std::cout << "Square of 8 is " << result2 << "\n";

    return 0;
}

Output:

Square of 5 is 25
Square of 8 is 64

Two threads run computeSquare concurrently, each writing into its own int via std::ref (references aren’t passed automatically — std::thread decays arguments to values unless you wrap them). Because each thread touches a separate variable, there’s no shared state and no race. Printing happens in main only after both join() calls return, so the output order is always deterministic even though the computation itself ran in parallel.

Example 2: Protecting shared data with a mutex

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

int counter = 0;
std::mutex counterMutex;

void incrementCounter(int times) {
    for (int i = 0; i < times; ++i) {
        std::lock_guard<std::mutex> lock(counterMutex);
        ++counter;
    }
}

int main() {
    std::vector<std::thread> threads;

    for (int i = 0; i < 4; ++i) {
        threads.push_back(std::thread(incrementCounter, 10000));
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final counter value: " << counter << "\n";

    return 0;
}

Output:

Final counter value: 40000

Four threads each increment the same global counter 10,000 times, for an expected total of 40,000. Without protection, ++counter is not atomic — it involves a read, an increment, and a write, and two threads can interleave those steps and lose an update. The std::lock_guard acquires counterMutex before touching counter and releases it automatically at the end of each loop iteration, so every increment is safely serialized and the final value is always exactly 40,000.

Example 3: Parallel work with std::async and std::future

#include <iostream>
#include <future>

long long sumRange(int start, int end) {
    long long sum = 0;
    for (int i = start; i <= end; ++i) {
        sum += i;
    }
    return sum;
}

int main() {
    std::future<long long> f1 = std::async(std::launch::async, sumRange, 1, 500000);
    std::future<long long> f2 = std::async(std::launch::async, sumRange, 500001, 1000000);

    long long result1 = f1.get();
    long long result2 = f2.get();
    long long total = result1 + result2;

    std::cout << "Sum from 1 to 1,000,000 is " << total << "\n";

    return 0;
}

Output:

Sum from 1 to 1,000,000 is 500000500000

std::async splits the summation into two halves, each running on its own thread (forced with std::launch::async). Unlike raw std::thread, std::async gives you back a std::future that can carry a return value across the thread boundary. Calling f.get() blocks until that thread finishes and then yields its result — this is usually a cleaner pattern than raw threads plus shared output variables when all you need is “run this, give me the answer.”

Under the Hood: What Happens Step by Step

  • Construction: std::thread t(func, args...) decay-copies its arguments into internal storage, then asks the OS to create a native thread (pthread_create on Linux) that will invoke func with those arguments.
  • Stack allocation: the OS reserves a new stack for the thread (commonly a few megabytes of virtual address space, committed lazily as it’s used). The heap and globals are not duplicated — they remain shared with every other thread in the process.
  • Scheduling: the new thread is placed on the OS scheduler’s run queue. If a free core is available, it can start executing immediately in true parallel with other threads; otherwise it waits for a time slice.
  • Locking: when a thread calls lock() on an uncontended std::mutex, it typically succeeds with a single fast atomic CPU instruction in user space. If the mutex is already held, the OS (via a futex on Linux) puts the calling thread to sleep so it consumes no CPU while waiting, then wakes it when the lock is released.
  • Joining: t.join() blocks the calling thread until the target thread’s function returns, then the OS reclaims the target thread’s stack and kernel resources.
  • Detaching: t.detach() tells the runtime you no longer care about tracking this thread. It keeps running independently and cleans itself up on completion, but you lose all ability to wait for it or check whether it’s still running.

Common Mistakes

Mistake 1: Data races on unsynchronized shared state

It’s tempting to skip synchronization for something as “simple” as incrementing an integer:

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

int counter = 0;

void incrementCounter(int times) {
    for (int i = 0; i < times; ++i) {
        counter++;  // no synchronization -- data race!
    }
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.push_back(std::thread(incrementCounter, 10000));
    }
    for (auto& t : threads) t.join();

    std::cout << "Final counter value: " << counter << "\n";
    // Expected 40000 -- but the actual value is unpredictable
    // and usually lower, because increments get lost when two
    // threads read the same old value before either writes back.
}

counter++ is really three steps: read, add one, write back. If two threads interleave those steps, one increment can be silently lost, and the final value comes out different (and often less) every time you run it. This is undefined behavior per the C++ standard, not just “unlucky.” Fix it with a std::mutex (Example 2 above) or, for a simple counter, with std::atomic<int>, which performs the increment as one indivisible hardware operation:

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

std::atomic<int> counter(0);

void incrementCounter(int times) {
    for (int i = 0; i < times; ++i) {
        counter++;
    }
}

int main() {
    std::vector<std::thread> threads;

    for (int i = 0; i < 4; ++i) {
        threads.push_back(std::thread(incrementCounter, 10000));
    }

    for (auto& t : threads) {
        t.join();
    }

    std::cout << "Final counter value: " << counter << "\n";

    return 0;
}

Output:

Final counter value: 40000

Mistake 2: Forgetting to join or detach a thread

#include <iostream>
#include <thread>

void printMessage() {
    std::cout << "Running in a thread\n";
}

int main() {
    std::thread t(printMessage);
    // Forgot to call t.join() or t.detach() here!
    std::cout << "Main function finished\n";
    return 0;
} // t's destructor runs while t is still joinable -- std::terminate() is called!

If a std::thread object is still joinable when it’s destroyed, the C++ runtime calls std::terminate() and the whole program aborts — this is a deliberate safety rule so a forgotten thread doesn’t silently vanish. The fix is to always join() (wait for it) or explicitly detach() (accept it will outlive this scope) before the std::thread variable goes out of scope:

#include <iostream>
#include <thread>

void printMessage() {
    std::cout << "Running in a thread\n";
}

int main() {
    std::thread t(printMessage);
    t.join();  // wait for the thread to finish before main exits
    std::cout << "Main function finished\n";
    return 0;
}

Output:

Running in a thread
Main function finished

Best Practices

  • Always pair every std::thread with exactly one join() or detach() before it’s destroyed — check with joinable() if you’re unsure.
  • Prefer RAII locks (std::lock_guard, std::unique_lock) over manually calling mutex.lock()/mutex.unlock(), so the lock is released even if an exception is thrown.
  • Keep critical sections (the code between lock and unlock) as short as possible — do expensive work outside the lock whenever you can.
  • Use std::atomic<T> for simple counters and flags instead of a full mutex; it’s cheaper and avoids lock contention.
  • Prefer std::async/std::future over raw threads when you just need a computed result back — it handles the return value and exception propagation for you.
  • When locking more than one mutex, always acquire them in the same global order everywhere in your program (or use std::scoped_lock, which locks several mutexes together safely) to avoid deadlock.
  • Avoid detach() unless you genuinely want a fire-and-forget background thread; a detached thread that outlives objects it references causes dangling-reference bugs.
  • Remember standard containers like std::vector are not thread-safe for concurrent writes — protect shared containers with a mutex just like any other shared data.
  • On Linux, compile and link with -pthread whenever your program includes <thread>.

Practice Exercises

  • Exercise 1: Write a program that spawns 5 threads, each printing a message that includes its loop index (e.g. “Thread 3 says hello”), then joins all of them before main returns.
  • Exercise 2: Take the mutex-protected counter from Example 2 and rewrite it using std::atomic<int> instead of std::mutex. Confirm the final value is still correct.
  • Exercise 3: Using std::condition_variable and std::mutex, write a simple producer/consumer program where one thread pushes numbers 1 through 10 onto a shared queue and a second thread pops and prints them as they arrive.

Summary

  • A thread is an independent sequence of execution that shares memory (heap, globals) with every other thread in the same process, but has its own stack.
  • std::thread creates a new OS-level thread; every joinable thread must be joined or detached before its object is destroyed, or the program calls std::terminate().
  • Shared, mutable data touched by multiple threads without synchronization causes data races, which are undefined behavior in C++.
  • std::mutex with an RAII lock (lock_guard/unique_lock) serializes access to a critical section; std::atomic<T> handles simple values without a lock.
  • std::async and std::future run a function on another thread and let you collect its return value later, which is simpler than managing raw threads for result-producing work.
  • Compile with -pthread on Linux, keep critical sections small, lock mutexes in a consistent order to avoid deadlock, and prefer higher-level tools (async, atomic) over raw threads when they fit the job.