Skip to content
Home
C++ Concurrency: Threads, Mutexes, and Atomics

C++ Concurrency: Threads, Mutexes, and Atomics

C/C++ C/C++ 8 min read 1678 words Beginner ExcellentWiki Editorial Team

C++ gained standardized threading support with C++11 and has improved it in every subsequent standard. The <thread>, <mutex>, <atomic>, and <future> headers provide a portable, type-safe concurrency model. Understanding these primitives is essential for writing correct and efficient multithreaded C++ programs.

Thread Management

Creating Threads

A std::thread executes a callable on a new thread of execution:

#include <thread>
#include <iostream>

void worker(int id) {
    std::cout << "Thread " << id << " running\n";
---

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);

    t1.join();  // Wait for t1 to finish
    t2.join();

    return 0;
---

Threads are movable but not copyable. You must call join() or detach() on every thread before it is destroyed, or the program terminates.

Thread with Lambda

std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
    threads.emplace_back([i] {
        std::cout << "Lambda thread " << i << "\n";
    });
---
for (auto& t : threads) t.join();

Passing Arguments

Arguments are copied into thread storage by default. Use std::ref to pass by reference:

void update(int& value) { value = 42; }

int x = 0;
std::thread t(update, std::ref(x));
t.join();
// x == 42

Mutual Exclusion

std::mutex

Protect shared data with a mutex. Lock it before accessing, unlock it when done:

#include <mutex>

std::mutex mtx;
int counter = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();
        ++counter;
        mtx.unlock();
    }
---

Manual lock()/unlock() is error prone. Use RAII wrappers instead.

std::lock_guard

Acquires the mutex on construction, releases on destruction:

void safe_increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        ++counter;
    }
---

std::unique_lock

More flexible than lock_guard. Supports deferred locking, timed locking, and conditional variable waiting:

std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// Do some work without the lock
lock.lock();
// Access shared data
lock.unlock();
// Re-lock later
lock.lock();

Deadlock Prevention

Acquire multiple locks in a consistent order. std::lock can lock multiple mutexes atomically:

std::mutex m1, m2;

void safe_transfer() {
    std::lock(m1, m2);  // Locks both without deadlock
    std::lock_guard<std::mutex> lock1(m1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(m2, std::adopt_lock);
    // Access shared data
---

Condition Variables

Condition variables allow threads to wait for a condition to become true:

#include <condition_variable>

std::mutex cv_mtx;
std::condition_variable cv;
bool ready = false;

void worker() {
    std::unique_lock<std::mutex> lock(cv_mtx);
    cv.wait(lock, []{ return ready; });
    // Proceed with work
---

void signal() {
    {
        std::lock_guard<std::mutex> lock(cv_mtx);
        ready = true;
    }
    cv.notify_one();  // or notify_all()
---

Always wait in a loop or use the predicate overload. Spurious wakeups can occur on any platform.

Atomic Operations

std::atomic

Atomics provide lock-free operations on shared variables:

#include <atomic>

std::atomic<int> atomic_counter{0};

void increment_atomic() {
    for (int i = 0; i < 100000; ++i) {
        atomic_counter.fetch_add(1, std::memory_order_relaxed);
    }
---

Memory Ordering

C++ defines six memory orderings that control how atomic operations synchronize between threads:

| Ordering | Description | Cost | |

Thread Pools

Creating and destroying threads for every task is expensive. A thread pool pre-creates a fixed number of threads and reuses them for incoming tasks. C++ does not include a thread pool in the standard library, but implementing one is straightforward:

class ThreadPool {
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queue_mutex;
    std::condition_variable cv;
    bool stop = false;

public:
    ThreadPool(size_t threads) {
        for (size_t i = 0; i < threads; ++i)
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock lock(queue_mutex);
                        cv.wait(lock, [this] {
                            return stop || !tasks.empty();
                        });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
    }

    template<class F>
    void enqueue(F &&f) {
        {
            std::unique_lock lock(queue_mutex);
            tasks.emplace(std::forward<F>(f));
        }
        cv.notify_one();
    }

    ~ThreadPool() {
        {
            std::unique_lock lock(queue_mutex);
            stop = true;
        }
        cv.notify_all();
        for (auto &w : workers) w.join();
    }
---;

Lock-Free Programming

Lock-free data structures avoid mutexes entirely by using atomic operations and careful memory ordering. They provide better scalability under high contention because threads never block:

std::atomic<int> counter{0};

// Thread-safe increment without mutex
counter.fetch_add(1, std::memory_order_relaxed);

// Compare-and-swap loop for more complex operations
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 2));

Lock-free programming is notoriously difficult to get right. The ABA problem, memory reclamation, and correct memory ordering require deep expertise. In most applications, well-designed mutex-based synchronization is preferable to lock-free data structures.

std::async and Futures

For simple asynchronous tasks, std::async launches a function in a potentially separate thread and returns a std::future to retrieve the result:

std::future<int> result = std::async(std::launch::async, [] {
    return compute_expensive_value();
---);
// Do other work...
int value = result.get();  // Blocks until result is ready

std::launch::async forces a new thread. std::launch::deferred defers execution until get() is called. The default policy lets the implementation decide. For fire-and-forget tasks or when you need more control over thread management, use std::thread directly or a thread pool.

FAQ

What is the difference between malloc and new in C++? malloc allocates raw memory without calling constructors; new allocates memory and calls the constructor. In C++, prefer new for objects. free vs delete follows the same pattern — delete calls the destructor.

How do I prevent memory leaks in C/C++? Use RAII (Resource Acquisition Is Initialization) in C++ — smart pointers like std::unique_ptr and std::shared_ptr automatically free memory. In C, always pair every malloc with a free and use tools like Valgrind or AddressSanitizer to detect leaks.

What is undefined behavior in C/C++? Undefined behavior occurs when code performs operations that the language standard does not define — dereferencing a null pointer, buffer overflow, signed integer overflow. The compiler may generate any code, including unexpected results or crashes.

Should I learn C or C++ first? Learn C first if you want to understand low-level memory and system programming. Learn C++ first if you want object-oriented features and the STL. Both are valuable; C++ builds on C concepts.

What is the difference between a header file and a source file? Header files (.h) declare interfaces — function prototypes, class definitions, macros. Source files (.c or .cpp) implement the declarations. Headers are #included; source files are compiled separately and linked.

Thread Pools

Creating and destroying threads for every task is expensive. A thread pool pre-creates a fixed number of threads and reuses them for incoming tasks. C++ does not include a thread pool in the standard library, but implementing one is straightforward:

class ThreadPool {
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queue_mutex;
    std::condition_variable cv;
    bool stop = false;

public:
    ThreadPool(size_t threads) {
        for (size_t i = 0; i < threads; ++i)
            workers.emplace_back([this] {
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock lock(queue_mutex);
                        cv.wait(lock, [this] {
                            return stop || !tasks.empty();
                        });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
    }

    template<class F>
    void enqueue(F &&f) {
        {
            std::unique_lock lock(queue_mutex);
            tasks.emplace(std::forward<F>(f));
        }
        cv.notify_one();
    }

    ~ThreadPool() {
        {
            std::unique_lock lock(queue_mutex);
            stop = true;
        }
        cv.notify_all();
        for (auto &w : workers) w.join();
    }
---;

Lock-Free Programming

Lock-free data structures avoid mutexes entirely by using atomic operations and careful memory ordering. They provide better scalability under high contention because threads never block:

std::atomic<int> counter{0};

// Thread-safe increment without mutex
counter.fetch_add(1, std::memory_order_relaxed);

// Compare-and-swap loop for more complex operations
int expected = counter.load();
while (!counter.compare_exchange_weak(expected, expected + 2));

Lock-free programming is notoriously difficult to get right. The ABA problem, memory reclamation, and correct memory ordering require deep expertise. In most applications, well-designed mutex-based synchronization is preferable to lock-free data structures.

std::async and Futures

For simple asynchronous tasks, std::async launches a function in a potentially separate thread and returns a std::future to retrieve the result:

std::future<int> result = std::async(std::launch::async, [] {
    return compute_expensive_value();
---);
// Do other work...
int value = result.get();  // Blocks until result is ready

std::launch::async forces a new thread. std::launch::deferred defers execution until get() is called. The default policy lets the implementation decide. For fire-and-forget tasks or when you need more control over thread management, use std::thread directly or a thread pool.

———-|————-|——| | memory_order_relaxed | No synchronization | Cheapest | | memory_order_consume | Data-dependent ordering | Rarely used | | memory_order_acquire | Prevents reordering before | Moderate | | memory_order_release | Prevents reordering after | Moderate | | memory_order_acq_rel | Acquire + release | More expensive | | memory_order_seq_cst | Sequential consistency | Default, most expensive |

For most code, the default sequential consistency is correct but may be slower. Relaxed ordering is useful for counters where only the final value matters.

std::atomic<int> counter{0};

// Fast counter (only final result matters)
void fast_count() {
    counter.fetch_add(1, std::memory_order_relaxed);
---

// Synchronizing flag
std::atomic<bool> flag{false};

void producer() {
    data = compute();
    flag.store(true, std::memory_order_release);
---

void consumer() {
    while (!flag.load(std::memory_order_acquire));
    process(data);  // Guaranteed to see the computed data
---

Futures and Promises

std::future and std::promise enable asynchronous result passing:

#include <future>

int compute_value() {
    return 42;
---

int main() {
    std::future<int> result = std::async(std::launch::async, compute_value);
    std::cout << "Result: " << result.get() << "\n";
    return 0;
---

std::packaged_task

Wraps a callable so its return value becomes a future:

std::packaged_task<int(int, int)> task([](int a, int b) {
    return a + b;
---);
std::future<int> result = task.get_future();
std::thread t(std::move(task), 3, 4);
t.join();
std::cout << result.get();  // 7

Common Pitfalls

Data Races

Any concurrent access to a non-atomic variable where at least one access is a write is a data race. Data races are undefined behavior — the compiler may generate incorrect code.

Thread Safety of STL Containers

Standard containers are not thread-safe for concurrent writes. Either use external synchronization or a dedicated concurrent container library.

False Sharing

When threads on different cores modify variables on the same cache line, the cache line bounces between cores. Align data to cache line boundaries (typically 64 bytes) to avoid this:

struct alignas(64) AlignedCounter {
    std::atomic<int> value{0};
    char padding[60];  // Fill the cache line
---;

Exception Safety

A mutex must be unlocked even if an exception is thrown. Use std::lock_guard or std::unique_lock to ensure automatic unlocking.

Best Practices

Minimize shared state between threads. Prefer message passing via queues over shared memory with locks. Use higher-level abstractions (std::async, thread pools) instead of managing threads manually. Profile with ThreadSanitizer to detect races. Use lock-free data structures only when profiling proves they are necessary — they are harder to reason about than locked alternatives.

For a comprehensive overview, read our article on C File Io Guide.

For a comprehensive overview, read our article on C Memory Management.

Section: C/C++ 1678 words 8 min read Beginner 756 articles in section Report inaccuracy Back to top