Skip to content
C++
Library
since C++11
Advanced

std::thread, mutex, atomic, condition_variable

C++11 portable threading model — thread creation, mutual exclusion, lock-free atomics, and condition-variable signaling for safe concurrent programs.

std::threadsince C++11

std::thread represents an OS thread of execution that begins running immediately upon construction and must be either joined or detached before its destructor runs, or std::terminate is called.

Overview

C++11 standardized a portable threading model across <thread>, <mutex>, <atomic>, and <condition_variable>. The model is layered: std::thread creates OS threads, mutexes protect shared state with RAII wrappers, std::atomic<T> provides wait-free or lock-free access to individual values, and std::condition_variable coordinates producers and consumers. C++17 added std::scoped_lock and std::shared_mutex; C++20 added std::jthread with cooperative cancellation via std::stop_token.

The critical invariant: any access to shared, non-atomic data from multiple threads where at least one access is a write is a data race — undefined behavior, regardless of how unlikely the interleaving seems at runtime.

Syntax

Thread lifecycle

cpp
#include <thread>

// Construction starts execution immediately
std::thread t(callable, args...);  // C++11

// Transfer ownership (threads are move-only)
std::thread t2 = std::move(t);    // C++11

t2.join();      // block until thread exits
t2.detach();    // sever handle; thread runs independently
t2.joinable();  // false after join/detach or default-construction
std::thread::hardware_concurrency();  // hint: logical cores available

C++20: std::jthread

cpp
#include <thread>

// Joins automatically on destruction; supports cooperative cancellation
std::jthread t([](std::stop_token st) {     // C++20
    while (!st.stop_requested()) {
        // process work
    }
});
t.request_stop();  // sets stop token; thread checks st.stop_requested()
// destructor calls request_stop() + join() automatically

Mutex and lock wrappers

cpp
#include <mutex>
#include <shared_mutex>

std::mutex mtx;
std::recursive_mutex rmtx;     // same thread may lock multiple times
std::timed_mutex tmtx;         // try_lock_for / try_lock_until
std::shared_mutex shmtx;       // C++17 — reader-writer lock

// RAII wrappers
std::lock_guard<std::mutex> lg(mtx);       // C++11 — no unlock/relock
std::unique_lock<std::mutex> ul(mtx);      // C++11 — can unlock, needed for cv
std::scoped_lock sl(mtx, mtx2);            // C++17 — deadlock-safe multi-lock
std::shared_lock<std::shared_mutex> shl(shmtx);  // C++17 — shared (read) access

Atomics

cpp
#include <atomic>

std::atomic<int> n{0};           // C++11
n.store(1, std::memory_order_relaxed);
int v = n.load(std::memory_order_acquire);
n.fetch_add(1);                  // returns old value
n.exchange(42);                  // write, return old
int expected = 5;
n.compare_exchange_strong(expected, 6);  // CAS: if n==expected, n=6

Examples

Thread-safe work queue (producer-consumer)

cpp
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <optional>

template<typename T>
class WorkQueue {
    std::queue<T>            q_;
    std::mutex               mtx_;
    std::condition_variable  cv_;
    bool                     closed_{false};

public:
    void push(T item) {
        {
            std::lock_guard lock(mtx_);  // CTAD: C++17
            q_.push(std::move(item));
        }
        cv_.notify_one();
    }

    void close() {
        { std::lock_guard lock(mtx_); closed_ = true; }
        cv_.notify_all();
    }

    // Returns nullopt when closed and empty
    std::optional<T> pop() {
        std::unique_lock lock(mtx_);
        cv_.wait(lock, [&]{ return !q_.empty() || closed_; });
        if (q_.empty()) return std::nullopt;
        T item = std::move(q_.front());
        q_.pop();
        return item;
    }
};

int main() {
    WorkQueue<int> q;

    std::jthread producer([&](std::stop_token st) {   // C++20
        for (int i = 0; i < 20 && !st.stop_requested(); ++i)
            q.push(i);
        q.close();
    });

    std::jthread consumer([&] {
        while (auto item = q.pop())
            process(*item);
    });
}

Reader-writer cache with shared_mutex (C++17)

cpp
#include <shared_mutex>
#include <unordered_map>
#include <string>

class Cache {
    mutable std::shared_mutex mtx_;    // C++17
    std::unordered_map<std::string, std::string> data_;

public:
    std::string get(const std::string& key) const {
        std::shared_lock lock(mtx_);   // C++17 — many readers simultaneously
        auto it = data_.find(key);
        return it != data_.end() ? it->second : "";
    }

    void set(const std::string& key, std::string value) {
        std::unique_lock lock(mtx_);   // exclusive write
        data_.insert_or_assign(key, std::move(value));
    }
};

Lock-free reference counter with acquire/release

cpp
#include <atomic>

struct Resource {
    std::atomic<int> refcount{1};   // C++11

    void retain() {
        // Relaxed: only care that the increment is atomic, no ordering needed
        refcount.fetch_add(1, std::memory_order_relaxed);
    }

    void release() {
        // Release: all writes in this thread happen-before the decrement
        if (refcount.fetch_sub(1, std::memory_order_release) == 1) {
            // Acquire: see all writes from threads that released
            std::atomic_thread_fence(std::memory_order_acquire);
            delete this;
        }
    }
};

The release on fetch_sub ensures the destructor sees all writes made before the final release(). This is the idiom used by std::shared_ptr's control block.

Locking multiple mutexes without deadlock

cpp
std::mutex accountA_mtx, accountB_mtx;

void transfer(Account& from, Account& to, int amount) {
    // std::scoped_lock (C++17) acquires both atomically using deadlock avoidance
    std::scoped_lock lock(accountA_mtx, accountB_mtx);
    from.balance -= amount;
    to.balance   += amount;
}

// C++11 equivalent using std::lock + adopt_lock
void transfer_cpp11(Account& from, Account& to, int amount) {
    std::unique_lock l1(accountA_mtx, std::defer_lock);   // C++11
    std::unique_lock l2(accountB_mtx, std::defer_lock);
    std::lock(l1, l2);   // acquires both, deadlock-free
    from.balance -= amount;
    to.balance   += amount;
}

std::async vs std::thread — task-based parallelism

cpp
#include <future>
#include <numeric>
#include <vector>

// Prefer async for tasks that return values
std::future<long long> fut = std::async(
    std::launch::async,
    [](const std::vector<int>& v) {
        return std::accumulate(v.begin(), v.end(), 0LL);
    },
    std::cref(data)
);

// Do other work, then retrieve
long long sum = fut.get();  // blocks if not ready; re-throws exceptions

// std::promise for manual value injection across threads
std::promise<std::string> prom;
std::future<std::string>  fut2 = prom.get_future();

std::jthread([p = std::move(prom)]() mutable {   // C++20
    p.set_value(compute_result());
});

std::string result = fut2.get();

Best Practices

Prefer std::jthread over std::thread in new C++20 code. It eliminates the terminate-on-destruction footgun and provides a built-in cancellation mechanism through std::stop_token without requiring external flags.

Prefer task-based (std::async) over thread-based for result-returning work. std::async handles thread exhaustion gracefully (it may run on the calling thread), propagates exceptions to the caller via future::get(), and avoids the "detach and pray" pattern.

Hold locks for the minimum duration. Lock, copy or move data out, unlock, then process. Long critical sections serialize threads and destroy parallelism. Use braces to scope lock_guard.

Use std::memory_order_relaxed for counters with no inter-thread ordering requirements (hit counters, statistics). Use acquire/release pairs for producer-consumer flag handoff. Reserve seq_cst (the default) for cases where you need a total order visible to all threads — it inserts full memory barriers on x86 stores and is measurably slower on ARM.

Never call std::thread::detach() unless the thread's lifetime is bounded by a global or static object. Detached threads that outlive the objects they capture cause undefined behavior at scope exit; there is no way to know when they finish.

Common Pitfalls

Destroying a joinable thread — std::terminate

cpp
void bad() {
    std::thread t(work);
}  // ~thread() on joinable thread calls std::terminate()
   // Fix: t.join() before scope exit, or use std::jthread (C++20)

Dangling references in detached threads

cpp
void bad() {
    std::string msg = "hello";
    std::thread t([&msg]{ use(msg); });  // captures reference
    t.detach();
}  // msg destroyed; thread still running → UB
   // Fix: capture by value, or don't detach

Spurious wakeups — never wait without a predicate

cpp
// Wrong: condition_variable can wake spuriously
cv.wait(lock);

// Correct: always supply a predicate
cv.wait(lock, []{ return ready; });
// Equivalent to: while (!ready) cv.wait(lock);

Data race on non-atomic flag

cpp
bool stop = false;   // not atomic!

std::thread t([&]{ while (!stop) work(); });
stop = true;         // data race — UB even if "obviously works"
t.join();

// Fix: std::atomic<bool> stop{false};
// Or: use std::jthread + std::stop_token

std::async with deferred launch

cpp
// std::launch::deferred runs on the calling thread at get() time — not async!
auto f = std::async(std::launch::deferred, heavy_work);
f.get();  // blocks here, single-threaded — no parallelism

// Always specify std::launch::async if you need a real thread
auto f2 = std::async(std::launch::async, heavy_work);

std::jthread and Cooperative Cancellation (C++20)

std::jthread is the C++20 successor to std::thread that eliminates two of its most dangerous properties. It joins automatically on destruction (preventing std::terminate) and carries a built-in std::stop_source/std::stop_token pair for cooperative cancellation — no external std::atomic<bool> flag required.

A thread function that accepts a std::stop_token as its first argument will receive the token automatically. The token is polled; the thread is never forcibly killed, which means it cleans up RAII objects normally.

cpp
// C++20
#include <thread>
#include <chrono>

std::jthread worker([](std::stop_token st) {
    while (!st.stop_requested()) {
        do_incremental_work();
    }
    // cleanup runs here — RAII destructors fire normally
});

// Somewhere else, signal cancellation:
worker.request_stop();
// destructor of worker calls request_stop() + join() if not already done

std::stop_callback lets you register a callback that fires when cancellation is requested — useful for waking a blocked condition_variable_any::wait without polling:

cpp
// C++20
#include <thread>
#include <condition_variable>
#include <mutex>

void interruptible_wait(std::stop_token st,
                        std::condition_variable_any& cv,
                        std::mutex& mtx) {
    std::unique_lock lock(mtx);
    // stop_callback wakes the cv when stop is requested
    std::stop_callback cb(st, [&cv]{ cv.notify_all(); });
    cv.wait(lock, [&]{ return data_ready() || st.stop_requested(); });
}

Compared to std::thread, std::jthread adds roughly one shared_ptr-sized control block for the stop_source. In practice this overhead is negligible; prefer std::jthread unconditionally in new C++20 code unless you are interfacing with a legacy API that expects std::thread.

Thread-Local Storage

thread_local gives every thread its own independent copy of a variable, initialized on first use in that thread. It is the C++11 successor to __thread (GCC) and __declspec(thread) (MSVC). Thread-local variables can be function-scoped, namespace-scoped, or class-scoped (static thread_local members).

cpp
// C++11
#include <thread>
#include <cstdio>

thread_local int call_count = 0;   // each thread has its own copy

void task(int id) {
    ++call_count;   // never races — each thread touches its own copy
    std::printf("thread %d: call_count = %d\n", id, call_count);
}

int main() {
    std::jthread t1([](){ task(1); task(1); });  // C++20
    std::jthread t2([](){ task(2); });
    // t1 prints 1, 2; t2 prints 1 — independent counters
}

Function-static thread_local variables are initialized the first time control passes through their declaration in a given thread. Namespace-scope thread_local variables are initialized before the first use of any function in that translation unit within the thread — but the order across translation units is unspecified (the same "static initialization order fiasco" applies per-thread).

cpp
// C++11 — common pattern: per-thread scratch buffer avoids heap allocation
thread_local std::vector<char> scratch_buf;

void encode(std::span<const uint8_t> data, std::vector<uint8_t>& out) {
    scratch_buf.resize(data.size() * 2);   // no mutex needed
    // ... encode into scratch_buf, copy to out ...
}

In shared libraries (.so / .dll), thread_local variables require TLS support from the dynamic linker. On Linux this is provided by the pthread TLS ABI (__tls_get_addr). Marking a thread_local variable __attribute__((tls_model("initial-exec"))) (GCC/Clang) selects a faster access model at the cost of preventing the library from being dlopen-ed after program start. For most application-level code, the default local-dynamic model is correct.

Thread Naming and CPU Affinity (POSIX)

Giving threads human-readable names dramatically improves debuggability. Tools like gdb, perf, htop, and Linux's /proc/<pid>/task/<tid>/comm all display thread names; without names every worker shows up as the process name, making profiling and crash analysis painful.

cpp
// C++11 + POSIX — Linux/macOS
#include <thread>
#include <pthread.h>

std::jthread io_thread([](std::stop_token st) {   // C++20
    // Name the current thread (Linux limit: 15 chars + NUL)
    pthread_setname_np(pthread_self(), "io-worker");

    while (!st.stop_requested()) {
        poll_io();
    }
});

On macOS the signature differs slightly: pthread_setname_np(name) takes only the name (no pthread_t argument) because macOS only allows a thread to name itself.

CPU affinity pins a thread to a specific set of logical cores, reducing cache migration overhead in latency-sensitive or real-time workloads:

cpp
// POSIX (Linux) — pin thread to cores 0 and 1
#include <pthread.h>
#include <sched.h>

void pin_to_cores(std::thread& t, std::initializer_list<int> cores) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    for (int c : cores) CPU_SET(c, &cpuset);
    int rc = pthread_setaffinity_np(t.native_handle(),
                                    sizeof(cpu_set_t), &cpuset);
    if (rc != 0) throw std::system_error(rc, std::generic_category());
}

// Usage
std::thread worker(render_loop);
pin_to_cores(worker, {2, 3});
worker.join();

std::thread::native_handle() returns the underlying pthread_t on POSIX platforms, enabling direct use of platform APIs. Be aware that affinity settings interact with the OS scheduler; over-constraining threads can hurt throughput on systems with variable workloads.

Additional Common Pitfalls

Exceptions thrown inside a thread call std::terminate

cpp
// C++11
void bad_thread() {
    std::thread t([]{ throw std::runtime_error("oops"); });
    t.join();
    // terminate() was already called before join() — the exception escaped
    // the thread function with no handler
}

// Fix: catch inside the thread and communicate via promise/atomic
void good_thread() {
    std::promise<void> done;
    std::thread t([&done]{
        try {
            risky_work();
            done.set_value();
        } catch (...) {
            done.set_exception(std::current_exception());
        }
    });
    t.join();
    done.get_future().get();  // re-throws on the calling thread
}

An uncaught exception that propagates out of a thread function immediately calls std::terminate. There is no cross-thread exception propagation in the basic threading model; use std::promise/std::future or std::async (which captures exceptions automatically) to relay errors to the joining thread.

Misreading thread IDs

cpp
// C++11
#include <thread>
#include <iostream>

std::thread::id main_id = std::this_thread::get_id();

void check() {
    if (std::this_thread::get_id() == main_id)
        std::cout << "on main thread\n";
    else
        std::cout << "on worker thread\n";
}

std::thread::id is an opaque comparable type — not an integer. Do not cast it to int or store it as a raw number; its value may be reused after a thread exits. The << operator on std::ostream is provided for logging, but the format is implementation-defined. Use std::this_thread::get_id() == some_thread.get_id() for identity checks and reserve numeric IDs (e.g. gettid()) for OS-level tooling.

Detached thread outliving a local variable

cpp
// C++11
void risky() {
    std::vector<int> data = load_data();   // local variable
    std::thread t([&data]{                 // captures reference
        heavy_computation(data);
    });
    t.detach();
}  // data destroyed here; thread still running → undefined behavior

// Fix option 1: capture by value (copy the data)
std::thread t([data = std::move(data)]{ heavy_computation(data); });
t.detach();

// Fix option 2: don't detach — use jthread and let it join on destruction
std::jthread t2([data = std::move(data)](std::stop_token){ // C++20
    heavy_computation(data);
});

Detached threads are rarely the right tool. If the intent is "fire and forget until program exit," store the std::jthread in a long-lived container instead; that way destruction order is predictable and the threads are cancelled and joined cleanly at shutdown.

See Also

  • std::future / std::promise — values across thread boundaries
  • std::atomic_ref (C++20) — atomic access to non-atomic objects
  • std::latch / std::barrier (C++20) — one-shot and reusable thread synchronization points
  • std::semaphore (C++20) — counting and binary semaphores
  • std::execution policies (C++17) — parallel STL algorithms
  • std::coroutine — cooperative concurrency without OS threads