Threads and Concurrency

Modern computers have many cores, and C++ lets a program use them: std::thread runs functions in parallel, mutexes protect shared data, and std::async runs a task and gives you its result later. Concurrency is powerful but subtle — this lesson covers the safe essentials.

Starting threads

A std::thread starts running a function as soon as it's created. Before the std::thread object is destroyed you must join it — wait for it to finish — or the program terminates:

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

int main() {
    std::vector<long long> results(4);
    std::vector<std::thread> workers;
    for (int t = 0; t < 4; ++t) {
        workers.emplace_back([t, &results] {
            long long sum = 0;
            for (long long i = t * 250'000; i < (t + 1) * 250'000; ++i) sum += i;
            results[t] = sum;          // each thread writes only its own slot
        });
    }
    for (auto& w : workers) w.join();  // wait for all four
    long long total = 0;
    for (auto r : results) total += r;
    std::cout << "sum of 0..999999 = " << total << '\n';
}
Output
sum of 0..999999 = 499999500000

C++20 adds std::jthread, which joins automatically in its destructor (RAII again) and supports cooperative cancellation. Prefer it where available — GCC's and Microsoft's libraries have it, but Apple's libc++ still treats it as experimental, so this lesson's examples use std::thread.

Data races

When two threads access the same variable at the same time and at least one writes, that's a data race — undefined behaviour. Even ++counter is a read, an add and a write, and two threads can interleave them and lose updates:

C++
int counter = 0;
// four threads, each running:  for (int i = 0; i < 100'000; ++i) ++counter;

Five runs of this program on the test machine printed totals between 113,156 and 126,423 — never the expected 400,000, and different every time.

ThreadSanitizer (-fsanitize=thread) detects data races while testing.

Mutexes

A std::mutex lets only one thread at a time into a critical section. Lock it with an RAII guard — std::lock_guard or std::scoped_lock — so it's always unlocked, even if an exception is thrown:

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

int main() {
    int counter = 0;
    std::mutex m;
    std::vector<std::thread> threads;
    for (int t = 0; t < 4; ++t) {
        threads.emplace_back([&] {
            for (int i = 0; i < 100'000; ++i) {
                std::lock_guard lock{m};
                ++counter;
            }
        });
    }
    for (auto& th : threads) th.join();
    std::cout << "counter = " << counter << '\n';
}
Output
counter = 400000

Keep critical sections short. When code locks several mutexes, std::scoped_lock locks them all at once without risk of deadlock.

Atomics

For a single counter or flag, std::atomic is simpler and faster than a mutex: its operations are indivisible.

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

int main() {
    std::atomic<int> counter{0};
    std::vector<std::thread> threads;
    for (int t = 0; t < 4; ++t) {
        threads.emplace_back([&counter] {
            for (int i = 0; i < 100'000; ++i) ++counter;
        });
    }
    for (auto& th : threads) th.join();
    std::cout << "counter = " << counter << '\n';
}
Output
counter = 400000

std::async and futures

std::async runs a function — possibly on another thread — and returns a std::future from which you get() the result, waiting if necessary. Exceptions thrown in the task are rethrown by get():

C++
#include <future>
#include <iostream>
#include <numeric>
#include <vector>

long long sum_range(const std::vector<int>& v, std::size_t begin, std::size_t end) {
    return std::accumulate(v.begin() + begin, v.begin() + end, 0LL);
}

int main() {
    std::vector<int> data(1'000'000);
    std::iota(data.begin(), data.end(), 1);

    auto first_half = std::async(std::launch::async, sum_range, std::cref(data), 0, data.size() / 2);
    long long second_half = sum_range(data, data.size() / 2, data.size());
    std::cout << "total = " << first_half.get() + second_half << '\n';
}
Output
total = 500000500000

Parallel algorithms

Many standard algorithms accept an execution policy as a first argument — std::execution::par asks the library to run them in parallel, e.g. std::sort(std::execution::par, v.begin(), v.end()). Support varies: GCC needs Intel's TBB library for it, and Apple's libc++ doesn't provide it yet.

Advice

  • Share as little mutable data between threads as possible; give each thread its own data and combine results at the end.
  • Protect anything shared with a mutex or make it atomic.
  • Use RAII lock guards, never manual lock()/unlock(), and join every thread (or use std::jthread).
  • Prefer higher-level tools (std::async, parallel algorithms, thread pools from libraries) to managing threads by hand.
  • Test with ThreadSanitizer.

Try it yourself

Count the primes below 200,000 using four threads, each checking a quarter of the range and adding to an atomic counter.

Try it yourself · main.cpp
Edit the code, copy it into main.cpp, then compile and run it with c++ -std=c++20 -Wall main.cpp -o main && ./main. How to set up a C++ compiler
Output
17984 primes below 200000