C++ course menu
C++ Threads and Concurrency: std::thread, mutex, atomic and async
An introduction to multithreading in C++: creating threads with std::thread and std::jthread, join, data races, protecting shared data with std::mutex and std::lock_guard, std::atomic counters, std::async with futures, and deadlocks.
On this page
Modern processors have several cores. A program that uses only one thread uses only one core. Multithreading lets one program do several things at the same time: process data in parallel, keep a user interface responsive while downloading, or serve many clients at once. C++ has had a standard thread library since C++11.
Concurrency is powerful, but it introduces a new class of bugs. This lesson covers the essentials and the most important safety rules.
Starting a thread#
std::thread (from <thread>) runs a function on a new thread. Before the std::thread object is destroyed, you must join it (wait for it to finish):
#include <iostream>
#include <thread>
void count_to(int n, long long& result) {
long long sum = 0;
for (int i = 1; i <= n; ++i) sum += i;
result = sum;
}
int main() {
long long a = 0;
long long b = 0;
std::thread t1(count_to, 1'000'000, std::ref(a));
std::thread t2(count_to, 2'000'000, std::ref(b));
t1.join(); // wait for both threads to finish
t2.join();
std::cout << a << '\n' << b << '\n';
}500000500000
2000001000000- Arguments are copied into the thread. To pass a reference, wrap it in
std::ref. join()blocks until the thread finishes. Destroying astd::threadthat is still joinable terminates the program.- Each thread writes to a different variable here, so there is no conflict.
In C++20, std::jthread joins automatically in its destructor, which is safer:
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<int> results(4);
{
std::vector<std::jthread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([i, &results] { results[i] = i * i; });
}
} // every jthread joins here
for (int r : results) std::cout << r << ' ';
std::cout << '\n';
}0 1 4 9Each thread writes only its own element, so this is safe. The order in which threads run is unpredictable, but the results are collected by position and printed after all threads finish, so the output is always the same.
Data races#
If two threads access the same variable at the same time, and at least one of them writes, that is a data race: undefined behaviour. The classic example is a shared counter:
int counter = 0;
auto work = [&counter] {
for (int i = 0; i < 100000; ++i) ++counter; // read, add, write: not atomic!
};
std::thread t1(work);
std::thread t2(work);
t1.join();
t2.join();
std::cout << counter << '\n'; // often less than 200000, and different each run++counter is really three steps (read, add, write). When two threads interleave those steps, increments get lost. The result is wrong sometimes, which makes these bugs hard to find.
Protecting data with a mutex#
A mutex ("mutual exclusion") lets only one thread at a time run a section of code. Lock it with std::lock_guard, which unlocks automatically at the end of the scope (RAII):
#include <iostream>
#include <mutex>
#include <thread>
int main() {
int counter = 0;
std::mutex m;
auto work = [&] {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(m); // locked until the end of this iteration
++counter;
}
};
std::thread t1(work);
std::thread t2(work);
t1.join();
t2.join();
std::cout << counter << '\n';
}200000Now the result is correct every time. Keep locked sections short: while one thread holds the lock, the others wait.
std::atomic for simple counters#
For a single shared number, std::atomic (from <atomic>) is simpler and faster than a mutex. Its operations are indivisible:
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::atomic<int> hits{0};
std::vector<std::thread> threads;
for (int t = 0; t < 4; ++t) {
threads.emplace_back([&hits] {
for (int i = 0; i < 50000; ++i) ++hits;
});
}
for (auto& th : threads) th.join();
std::cout << hits << '\n';
}200000Atomics protect one variable. When several variables must change together, use a mutex.
std::async: run a task and get its result#
std::async (from <future>) runs a function, possibly on another thread, and returns a std::future you can ask for the result later. Exceptions thrown by the task are delivered when you call get():
#include <future>
#include <iostream>
#include <numeric>
#include <vector>
long long sum_range(const std::vector<int>& v, std::size_t from, std::size_t to) {
return std::accumulate(v.begin() + from, v.begin() + to, 0LL);
}
int main() {
std::vector<int> data(1'000'000);
std::iota(data.begin(), data.end(), 1); // 1, 2, ..., 1000000
std::size_t half = data.size() / 2;
auto first = std::async(std::launch::async, sum_range, std::cref(data), 0, half);
auto second = std::async(std::launch::async, sum_range, std::cref(data), half, data.size());
std::cout << first.get() + second.get() << '\n';
}500000500000Splitting work into independent pieces that combine at the end is the simplest and safest way to use parallelism: no shared writes, so no locks needed.
Deadlocks#
A deadlock happens when two threads each hold a lock the other is waiting for, so both wait forever. It typically appears when two mutexes are locked in different orders in different places. To avoid it:
- Lock several mutexes at once with
std::scoped_lock lock(m1, m2);(C++17), which locks them without deadlocking. - Always acquire locks in the same order.
- Keep locked sections short, and never call unknown code while holding a lock.
Guidelines#
- Prefer no sharing: give each thread its own data and combine results at the end.
- If you must share, protect every access with the same mutex, or use atomics for single values.
- Use RAII lock types (
std::lock_guard,std::scoped_lock,std::unique_lock), never manuallock()/unlock(). - Higher-level tools (
std::async, thread pools, parallel algorithms such asstd::sort(std::execution::par, ...)) are easier to get right than raw threads. - Compile with
-fsanitize=thread(ThreadSanitizer in GCC and Clang) to detect data races while testing.
Common mistakes#
Practice#
Practice exercise
Count the prime numbers below 200,000 using 4 threads: each thread checks a quarter of the range and stores its count in its own slot of a vector. Print the total.
Show solution
#include <iostream>
#include <thread>
#include <vector>
bool is_prime(int n) {
if (n < 2) return false;
for (int d = 2; d * d <= n; ++d) {
if (n % d == 0) return false;
}
return true;
}
int main() {
const int limit = 200'000;
const int parts = 4;
std::vector<int> counts(parts, 0);
{
std::vector<std::jthread> workers;
for (int p = 0; p < parts; ++p) {
workers.emplace_back([p, &counts] {
int from = p * limit / parts;
int to = (p + 1) * limit / parts;
int count = 0;
for (int n = from; n < to; ++n) {
if (is_prime(n)) ++count;
}
counts[p] = count; // each thread writes only its own slot
});
}
}
int total = 0;
for (int c : counts) total += c;
std::cout << total << '\n';
}17984There is no shared write, so no mutex is needed. There are 17,984 primes below 200,000.
Key takeaway#
Finished this lesson?