Threads, mutexes and atomics

C++ · Advanced · 18 min

Multicore hardware is the norm, and data races are undefined behaviour, so correct synchronisation is a core skill.

std::thread starts a function on a new thread. You must join() it (wait) or detach() it before the std::thread object is destroyed, or the program calls std::terminate. C++20's `std::jthread` joins automatically in its destructor (RAII again) and supports cooperative cancellation: the thread function can take a std::stop_token and poll stop_requested(), and request_stop() asks it to finish. Prefer jthread.

When threads share data and at least one writes, you need synchronization. An unsynchronized concurrent read and write is a data race, which is undefined behaviour, not just a wrong number.

  • std::mutex provides mutual exclusion. Never call lock()/unlock() by hand; use std::scoped_lock lock{m}; (C++17), which unlocks at scope exit even on exceptions. scoped_lock can lock several mutexes at once without deadlock. Use std::unique_lock when you need to unlock early or wait on a condition variable.
  • std::condition_variable lets a thread sleep until another notifies it that a condition (checked under the mutex) has become true.
  • std::atomic<T> makes individual operations on small values (++, load, store, fetch_add, compare_exchange) indivisible without a lock. Good for counters and flags; for invariants spanning several variables, use a mutex.

Higher-level tools: std::async returns a std::future with the result; C++20 adds std::latch, std::barrier and std::counting_semaphore.

Practical advice: share as little as possible, hold locks briefly and never call unknown code while holding one, and pass data to threads by value. Capturing locals by reference in a thread lambda is only safe if the thread is joined before those locals die. Test with -fsanitize=thread, which detects data races at run time.

Example

#include <atomic>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

int main() {
    std::atomic<int> hits{0};             // indivisible ++, no data race
    std::mutex m;
    std::vector<int> finished;            // shared, guarded by m

    {
        std::vector<std::jthread> workers;            // C++20: joins in its destructor
        for (int id = 0; id < 4; ++id) {
            workers.emplace_back([&, id] {
                for (int i = 0; i < 1000; ++i) ++hits;
                std::scoped_lock lock{m};              // unlocks at end of scope
                finished.push_back(id);
            });
        }
    }                                                  // all threads joined here

    std::cout << hits << ' ' << finished.size() << '\n';   // 4000 4

    std::jthread ticker([](std::stop_token st) {       // cooperative cancellation
        while (!st.stop_requested()) std::this_thread::yield();
    });
    ticker.request_stop();               // the destructor would do this too, then join
}

Remember

  • A data race (unsynchronised write + access) is undefined behaviour
  • Prefer std::jthread; lock with std::scoped_lock, never manual lock/unlock
  • Atomics for single counters/flags, mutexes for multi-variable invariants

Watch out for

  • Letting a std::thread object be destroyed while still joinable, which calls std::terminate.

Check yourself

  1. Two threads each run `++count;` 1000 times on a plain `int count = 0;`. What is guaranteed?
  2. What advantage does std::jthread have over std::thread?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: en.cppreference.com/w/cpp/thread