C++ Threads and Concurrency

Run work in parallel with std::thread, protect shared data with mutex and atomic, avoid data races, and get results back with std::async and futures.

Lesson 25 of 44Advanced18 min read

Modern computers have several processor cores, often 4, 8 or more. A normal program uses just one of them: it does one thing at a time, from top to bottom. If a task can be split into independent pieces, such as resizing a hundred photos or adding up different parts of a huge list, the other cores sit idle while one does all the work.

Threads let one program run several pieces of code at the same time. C++11 added threads to the standard library, so the same code runs on Windows, macOS and Linux. Threads can make heavy work much faster and keep programs responsive, for example by doing slow work in the background while the user interface keeps reacting.

Threads also bring a new kind of bug, when two threads change the same data at once. In this lesson you'll start and join threads, protect shared data with mutexes and atomics, and use std::async for results.

Starting and joining a thread

C++
#include <thread>

void work(int id) { /* ... */ }

thread t(work, 1);      // starts running work(1) now
// main carries on here at the same time
t.join();               // wait for t to finish
  • A std::thread starts running as soon as it's created. Pass the function and its arguments; a lambda works too.
  • join() waits for the thread to finish. Every thread must be joined (or detached) before its thread object is destroyed, otherwise the program terminates.
  • C++20's std::jthread joins automatically when it goes out of scope, which is safer.
  • thread::hardware_concurrency() tells you how many threads the computer can run at once.

Data races

A data race happens when two threads access the same variable at the same time and at least one of them writes to it. Even count++ isn't a single step: it reads the value, adds one, and writes it back. If two threads read the same old value, one increment is lost.

Data races are undefined behaviour. The program might give the right answer on your computer and a wrong one on a server, or only on Tuesdays. The rule is simple: if threads share data and any of them changes it, that access must be protected.

The safest design avoids sharing altogether: give each thread its own data to work on, and combine the results when they've finished.

mutex and atomic

A mutex (mutual exclusion) is a lock that only one thread can hold at a time. Wrap it in a lock_guard, which locks in its constructor and unlocks automatically when it goes out of scope:

C++
mutex m;
void addOrder(vector<int>& orders, int id) {
    lock_guard<mutex> lock(m);
    orders.push_back(id);          // only one thread at a time
}

For a single number such as a counter, std::atomic<int> is simpler and faster: count++ on an atomic is one indivisible step. Use atomics for simple counters and flags, and mutexes when several values or a container must change together.

std::async and futures

Often you just want to run a calculation in the background and collect its answer later. std::async does exactly that and returns a future, a placeholder for the result:

C++
#include <future>
future<long long> f = async(launch::async, sumRange, 1, 50000000);
// do other work...
long long result = f.get();       // waits if necessary, then returns the value

If the function throws an exception, get() re-throws it in the calling thread, so error handling works naturally. For many tasks, async is easier and safer than managing threads yourself.

C++ Threads Syntax

Concurrency essentials

#include <thread>
#include <mutex>
#include <atomic>
#include <future>
thread t(func, args...);  t.join();
mutex m;  { lock_guard<mutex> lock(m); /* protected */ }
atomic<int> counter{0};  counter++;
future<T> f = async(launch::async, func, args...);
T result = f.get();
unsigned cores = thread::hardware_concurrency();

C++ Threads Summary

  • std::thread runs a function concurrently; call join() to wait for it to finish.
  • A data race, two threads touching the same data with at least one writing, is undefined behaviour.
  • Protect shared data with a mutex and lock_guard, or use std::atomic for simple values.
  • Prefer giving each thread its own data and combining results afterwards.
  • std::async returns a future; get() waits for and returns the result.

C++ Threads Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Splitting work between threads

C++
#include <iostream>
#include <thread>
#include <vector>
using namespace std;

void sumRange(const vector<int>& data, size_t from, size_t to, long long& result) {
    long long total = 0;
    for (size_t i = from; i < to; i++) total += data[i];
    result = total;
}

int main() {
    vector<int> data(1000000);
    for (size_t i = 0; i < data.size(); i++) data[i] = i % 10;

    long long firstHalf = 0, secondHalf = 0;
    size_t mid = data.size() / 2;
    thread a(sumRange, cref(data), 0, mid, ref(firstHalf));
    thread b(sumRange, cref(data), mid, data.size(), ref(secondHalf));
    a.join();
    b.join();

    cout << "First half: " << firstHalf << endl;
    cout << "Second half: " << secondHalf << endl;
    cout << "Total: " << firstHalf + secondHalf << endl;
    return 0;
}
Output
First half: 2250000
Second half: 2250000
Total: 4500000
Line-by-line explanation
  1. Lines 6 to 10Each thread sums its own part and writes to its own result variable, so nothing is shared for writing.
  2. Lines 18 to 19Two threads start immediately. ref and cref pass references; without them, thread copies its arguments.
  3. Lines 20 to 21Wait for both before reading the results.
  4. Lines 23 to 25Printing happens in main after joining, so the output is always in the same order.
Example 2

Counting safely with atomic and mutex

C++
#include <atomic>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
using namespace std;

int main() {
    atomic<int> visits{0};
    vector<string> log;
    mutex logLock;

    auto worker = [&](int id) {
        for (int i = 0; i < 100000; i++) {
            visits++;
        }
        lock_guard<mutex> lock(logLock);
        log.push_back("worker " + to_string(id) + " done");
    };

    vector<thread> threads;
    for (int id = 1; id <= 4; id++) threads.emplace_back(worker, id);
    for (auto& t : threads) t.join();

    cout << "Visits: " << visits << endl;
    cout << "Log entries: " << log.size() << endl;
    return 0;
}
Output
Visits: 400000
Log entries: 4
Line-by-line explanation
  1. Line 10An atomic counter: each increment happens as one indivisible step.
  2. Lines 18 to 19lock_guard makes sure only one thread at a time adds to the log vector.
  3. Line 23Four threads all run the same lambda.
  4. Line 26Exactly 400,000 every time. With a plain int, it would often be less, because increments would be lost.
Example 3

Background work with async

C++
#include <future>
#include <iostream>
using namespace std;

long long countPrimes(int limit) {
    long long count = 0;
    for (int n = 2; n <= limit; n++) {
        bool prime = true;
        for (int d = 2; d * d <= n; d++) {
            if (n % d == 0) { prime = false; break; }
        }
        if (prime) count++;
    }
    return count;
}

int main() {
    future<long long> small = async(launch::async, countPrimes, 10000);
    future<long long> large = async(launch::async, countPrimes, 100000);

    cout << "Working in the background..." << endl;
    cout << "Primes up to 10,000: " << small.get() << endl;
    cout << "Primes up to 100,000: " << large.get() << endl;
    return 0;
}
Output
Working in the background...
Primes up to 10,000: 1229
Primes up to 100,000: 9592
Line-by-line explanation
  1. Lines 18 to 19Both calculations start straight away, each in its own thread.
  2. Line 21main keeps running while they work.
  3. Lines 22 to 23get() waits for each result if it isn't ready yet, then returns it.

Common C++ Threads Mistakes

Forgetting to join

Don't
int main() {
    thread t(work);
    return 0;                 // std::terminate: t was never joined
}
Do
int main() {
    thread t(work);
    t.join();
    return 0;
}

Sharing a plain counter

Don't
int count = 0;
// two threads each run: for (...) count++;    data race, wrong totals
Do
atomic<int> count{0};
// two threads each run: for (...) count++;    always correct

Locking by hand and forgetting to unlock

Don't
m.lock();
if (orders.empty()) return;   // returns while still locked: deadlock later
orders.pop_back();
m.unlock();
Do
lock_guard<mutex> lock(m);
if (orders.empty()) return;   // unlocked automatically
orders.pop_back();

C++ Threads in Real Projects

Threads in real software

Game engines run physics, audio and rendering on separate threads, and web servers handle many requests at once. C++17's parallel algorithms, such as sort(execution::par, ...), build on the same ideas.

C++ Threads FAQ

What is the difference between join and detach?

join() waits for the thread to finish, so you know its work is done before you carry on. detach() lets the thread keep running on its own with no way to wait for it or get a result; if main ends first, the program exits and the thread is stopped part-way. Prefer join, or std::jthread, and use detach only for true fire-and-forget work.

What is a deadlock and how do I avoid it?

A deadlock happens when two threads each hold a lock the other is waiting for, so both wait forever. The classic case is thread 1 locking a then b while thread 2 locks b then a. Always lock mutexes in the same order, or lock them together with C++17's scoped_lock lock(a, b);, which takes both using a built-in deadlock-avoidance algorithm.

What is a condition variable in C++?

A std::condition_variable lets a thread sleep until another thread tells it something has changed, instead of checking a flag in a busy loop. The waiting thread calls cv.wait(lock, [&] { return ready; }); with a unique_lock on a mutex, and the other thread sets ready under the same mutex and calls cv.notify_one(). The condition in the lambda protects against waking up when nothing has actually happened.

Do I need -pthread to compile threads with g++?

On Linux it's safest to add -pthread, as in g++ -std=c++20 -pthread main.cpp. Recent versions of the GNU C library build thread support into the main library, so many programs link without it, but older systems give "undefined reference to pthread_create" errors when it's missing. CMake handles it with find_package(Threads) and Threads::Threads.

What does thread_local mean?

A variable declared thread_local gets a separate copy in each thread, and each copy is destroyed when its thread ends. Because no other thread can see your copy, it needs no mutex. It's handy for per-thread caches, counters or random number engines.

C++ Threads Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the code so main waits for the thread to finish.

thread t(downloadFile);
t.();
Multiple choiceeasy

When does a std::thread start running its function?

Multiple choicemedium

What is a data race?

Multiple choicemedium

What does lock_guard do?

Predict the outputhard

Four threads each increment an atomic<int> 1,000 times. What's the final value?

C++ Threads Quiz

5 questions to check what you learned.

  1. Which header provides std::thread?

    • <process>
    • <async>
    • <mutex>
    • <thread>
  2. What does f.get() do on a future?

    • Starts the task
    • Cancels the task
    • Waits for the result if needed, then returns it
    • Returns immediately with 0
  3. When is std::atomic a good choice?

    • For protecting a whole vector
    • For simple shared counters and flags
    • For reading files
    • Never
  4. Why use ref() when passing a variable to std::thread?

    • It makes the thread faster
    • It locks the variable
    • It's required for every argument
    • std::thread copies its arguments, so ref is needed to pass a real reference
  5. What's the safest overall design for multithreaded work?

    • Share everything and add locks later
    • Use as many threads as possible
    • Give each thread its own data, then combine results after joining
    • Avoid join