Threads, jthread & async

C++ · Lesson 29 of 32 7 min read Updated

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.

C++
#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);
}
Output
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:

C++
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.

C++
#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());
}
Output
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.

C++
#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 here
Output
Main: 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.

C++
#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);
}
Output
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 a std::thread. The program terminates. Use std::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_lock to lock several at once.

Key points

  • std::thread runs a function in parallel; you must join() it before it is destroyed.
  • A data race (unsynchronised access to shared data) is undefined behaviour; use std::mutex with std::lock_guard, or std::atomic for single values.
  • C++20 std::jthread joins automatically and supports cooperative cancellation with std::stop_token.
  • std::async returns a std::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
C++
#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);
}
Output
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()?

2. Two threads increment the same int without any protection. What is this called?

3. What does a std::jthread do in its destructor?

4. What does future.get() do if the task threw an exception?

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