Threads, jthread & async
Modern computers have several processor cores, but a normal program uses only one at a time. Concurrency means doing several tasks during the same period, and with several cores they can truly run in parallel: one part of a program downloads a file while another updates the screen, or four cores each process a quarter of a large data set.
Since C++11 the standard library includes threads. In this lesson you will start threads, wait for them, protect shared data with mutexes and atomics, use C++20's self-joining std::jthread with stop requests, and get results back with std::async.
Starting a thread with std::thread
A thread is an independent path of execution inside your program. std::thread (header <thread>) starts running the function or lambda you give it immediately. Before the std::thread object is destroyed, you must call join(), which waits for the thread to finish.
#include <print>
#include <thread>
long long sum_range(int from, int to) {
long long total = 0;
for (int i = from; i <= to; ++i) {
total += i;
}
return total;
}
int main() {
long long first_half = 0;
long long second_half = 0;
std::thread t1([&first_half] { first_half = sum_range(1, 500'000); });
std::thread t2([&second_half] { second_half = sum_range(500'001, 1'000'000); });
t1.join(); // wait for both threads to finish
t2.join();
std::println("Sum of 1 to 1,000,000: {}", first_half + second_half);
}Sum of 1 to 1,000,000: 500000500000
Each thread writes to its own variable, and main reads them only after both join calls, so the result is always correct. The two halves may run at the same moment on two cores. If a std::thread is destroyed without being joined, the program calls std::terminate and stops. On Linux, add -pthread when compiling programs that use threads: g++ -std=c++23 -pthread app.cpp -o app.
Data races, mutexes and atomics
When two threads change the same variable without coordination, you have a data race, which is undefined behaviour. This counter is updated by two threads at once:
int counter = 0;
auto work = [&counter] {
for (int i = 0; i < 100'000; ++i) {
++counter; // read, add, write: threads can interleave
}
};
// two threads running work usually end with LESS than 200000, different every run++counter is really three steps: read the value, add one, write it back. If both threads read 500 at the same moment, both write 501, and one increment is lost. The fix is a mutex (mutual exclusion) from <mutex>: only one thread at a time can hold it. std::lock_guard is an RAII type that locks the mutex in its constructor and unlocks it in its destructor.
#include <atomic>
#include <mutex>
#include <print>
#include <thread>
int main() {
int counter = 0;
std::mutex counter_mutex;
auto work = [&] {
for (int i = 0; i < 100'000; ++i) {
std::lock_guard lock(counter_mutex); // locked until the end of this block
++counter;
}
};
std::atomic<int> hits{0};
auto count_hits = [&hits] {
for (int i = 0; i < 100'000; ++i) {
++hits; // atomic: no lost updates
}
};
std::thread a(work), b(work), c(count_hits), d(count_hits);
a.join();
b.join();
c.join();
d.join();
std::println("Counter with mutex: {}", counter);
std::println("Atomic hits: {}", hits.load());
}Counter with mutex: 200000 Atomic hits: 200000
For a single number, std::atomic<int> from <atomic> is simpler and faster: its operations cannot be interrupted halfway. For anything larger, such as updating a vector or two related variables together, use a mutex. When you must lock two mutexes at once, use std::scoped_lock (C++17), which locks them without risking a deadlock, where two threads wait for each other forever.
std::jthread: joining automatically and stopping politely
C++20 added std::jthread ("joining thread"). Its destructor automatically asks the thread to stop and then joins it, so you cannot forget join(). If its function takes a std::stop_token, the thread can check whether someone has requested a stop and finish cleanly.
#include <chrono>
#include <print>
#include <stop_token>
#include <thread>
int main() {
std::jthread backup([](std::stop_token stop) {
while (!stop.stop_requested()) {
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // pretend to work
}
std::println("Backup thread: stop received, saving and exiting");
});
std::this_thread::sleep_for(std::chrono::milliseconds(200));
std::println("Main: asking the backup thread to stop");
backup.request_stop();
} // backup is joined automatically hereMain: asking the backup thread to stop Backup thread: stop received, saving and exiting
The order of the two lines is fixed: the thread prints only after request_stop(), which main calls after its own message. Even without the explicit request_stop(), the jthread destructor would request a stop and wait. std::jthread is supported by GCC 10 and later and MSVC from Visual Studio 2019 16.9; Clang's libc++ completed it in version 20.
Getting results with std::async and std::future
Often you just want to run a function in the background and collect its return value later. std::async (header <future>) starts the task and returns a std::future, a placeholder for a result that will be ready later. Calling get() waits for the result.
#include <future>
#include <print>
int count_primes(int from, int to) {
int count = 0;
for (int n = from; n <= to; ++n) {
if (n < 2) {
continue;
}
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() {
auto low = std::async(std::launch::async, count_primes, 2, 50'000);
auto high = std::async(std::launch::async, count_primes, 50'001, 100'000);
int first = low.get(); // waits if not finished yet
int second = high.get();
std::println("Primes up to 50000: {}", first);
std::println("Primes up to 100000: {}", first + second);
}Primes up to 50000: 5133 Primes up to 100000: 9592
std::launch::async asks for a new thread. If the function throws an exception, it is stored in the future and thrown again from get(), so error handling works across threads. get() can be called only once per future.
Common mistakes
- Forgetting
join()on astd::thread. The program terminates. Usestd::jthread. - Sharing data without protection. Any variable written by one thread and read or written by another needs a mutex or an atomic.
- Capturing locals by reference in a thread that outlives them. Make sure every referenced variable lives until the thread is joined.
- Expecting a fixed print order from threads. Output from threads running at the same time can appear in any order and even mix within a line; collect results and print after joining.
- Locking mutexes in different orders. Two threads can deadlock; use
std::scoped_lockto lock several at once.
Key points
std::threadruns a function in parallel; you mustjoin()it before it is destroyed.- A data race (unsynchronised access to shared data) is undefined behaviour; use
std::mutexwithstd::lock_guard, orstd::atomicfor single values. - C++20
std::jthreadjoins automatically and supports cooperative cancellation withstd::stop_token. std::asyncreturns astd::future;get()waits for the result and rethrows any exception.- Print results after joining so output is predictable.
Exercise
A school in Jaipur has marks for three sections stored in three vectors. Use three std::async tasks to compute the total marks of each section in parallel, then print each section's total and the grand total. Use these marks: section A {78, 92, 65}, section B {88, 71, 95, 60}, section C {55, 83}.
Show solution
#include <functional>
#include <future>
#include <numeric>
#include <print>
#include <vector>
int total_marks(const std::vector<int>& marks) {
return std::accumulate(marks.begin(), marks.end(), 0);
}
int main() {
std::vector<int> a = {78, 92, 65};
std::vector<int> b = {88, 71, 95, 60};
std::vector<int> c = {55, 83};
auto fa = std::async(std::launch::async, total_marks, std::cref(a));
auto fb = std::async(std::launch::async, total_marks, std::cref(b));
auto fc = std::async(std::launch::async, total_marks, std::cref(c));
int ta = fa.get();
int tb = fb.get();
int tc = fc.get();
std::println("A: {}, B: {}, C: {}", ta, tb, tc);
std::println("Grand total: {}", ta + tb + tc);
}A: 235, B: 314, C: 138 Grand total: 687
std::cref passes each vector by reference instead of copying it into the task; the vectors live until main ends, long after the tasks finish. Printing happens only after every get(), so the output order is fixed.
Quick Quiz
1. What happens if a joinable std::thread is destroyed without join()?
Only std::jthread joins automatically; a joinable std::thread must be joined or detached.
2. Two threads increment the same int without any protection. What is this called?
Unsynchronised writes to shared data are a data race; use a mutex or std::atomic.
3. What does a std::jthread do in its destructor?
jthread signals its stop token and waits for the thread to finish.
4. What does future.get() do if the task threw an exception?
The exception is stored in the future and rethrown by get().
Learn C++ with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
