Advanced Primitives

This section covers advanced synchronization primitives: atomics for lock-free operations, condition variables for efficient waiting, and shared locks for reader/writer patterns.

Atomics: Lock-Free Operations

For operations on individual values, mutexes might be overkill. Atomic types provide lock-free thread safety for single variables.

An atomic operation completes entirely before any other thread can observe its effects. There is no intermediate state.

#include <iostream>
#include <thread>
#include <atomic>

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

void increment_many_times()
{
    for (int i = 0; i < 100000; ++i)
        ++counter;  // atomic increment
}

int main()
{
    std::thread t1(increment_many_times);
    std::thread t2(increment_many_times);

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << "\n";
    return 0;
}

No mutex, no lock guard, yet the result is always 200,000. The std::atomic<int> ensures that increments are indivisible.

When to Use Atomics

Atomics work best for single-variable operations: counters, flags, simple state. They are faster than mutexes when contention is low. But they cannot protect complex operations involving multiple variables—for that, you need mutexes.

Common atomic types include:

  • std::atomic<bool> — Thread-safe boolean flag

  • std::atomic<int> — Thread-safe integer counter

  • std::atomic<T*> — Thread-safe pointer

  • std::atomic<std::shared_ptr<T>> — Thread-safe shared pointer (C++20)

Any trivially copyable type can be made atomic.

Atomic Operations

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

value.store(42);              // atomic write
int x = value.load();         // atomic read
int old = value.exchange(10); // atomic read-modify-write
value.fetch_add(5);           // atomic addition, returns old value
value.fetch_sub(3);           // atomic subtraction, returns old value

// Compare-and-swap (CAS)
int expected = 10;
bool success = value.compare_exchange_strong(expected, 20);
// If value == expected, sets value = 20 and returns true
// Otherwise, sets expected = value and returns false

Condition Variables: Efficient Waiting

Sometimes a thread must wait for a specific condition before proceeding. You could loop, repeatedly checking:

// Inefficient busy-wait
while (!ready)
{
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

This works but wastes CPU cycles and introduces latency. Condition variables provide efficient waiting.

A condition variable allows one thread to signal others that something has changed. Waiting threads sleep until notified, consuming no CPU.

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

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

void worker()
{
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return ready; });  // wait until ready is true
    std::cout << "Worker proceeding!\n";
}

void signal_ready()
{
    {
        std::lock_guard<std::mutex> lock(mtx);
        ready = true;
    }
    cv.notify_one();  // wake one waiting thread
}

int main()
{
    std::thread t(worker);

    std::this_thread::sleep_for(std::chrono::seconds(1));
    signal_ready();

    t.join();
    return 0;
}

The worker thread calls cv.wait(), which atomically releases the mutex and suspends the thread. When signal_ready() calls notify_one(), the worker wakes up, reacquires the mutex, checks the condition, and proceeds.

The Predicate

The lambda []{ return ready; } is the predicate. wait() does not return until this evaluates to true. This guards against spurious wakeups—rare events where a thread wakes without notification. Always use a predicate.

Notification Methods

  • notify_one() — Wake a single waiting thread

  • notify_all() — Wake all waiting threads

Use notify_one() when only one thread needs to proceed (e.g., producer-consumer with single consumer). Use notify_all() when multiple threads might need to check the condition (e.g., broadcast events, shutdown signals).

Wait Variants

// Wait indefinitely
cv.wait(lock, predicate);

// Wait with timeout
auto status = cv.wait_for(lock, std::chrono::seconds(5), predicate);
// Returns true if predicate is true, false on timeout

// Wait until specific time point
auto status = cv.wait_until(lock, deadline, predicate);

Shared Locks: Readers and Writers

Consider a data structure that is read frequently but written rarely. A regular mutex serializes all access—but why block readers from each other? Multiple threads can safely read simultaneously; only writes require exclusive access.

Shared mutexes support this pattern:

// Fragments deliberately leave results and bindings unused; the pages
// explain the values in prose instead.
#if defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic ignored "-Wunused-but-set-variable"
#pragma GCC diagnostic ignored "-Wunused-variable"
#pragma GCC diagnostic ignored "-Wunused-parameter"
#pragma GCC diagnostic ignored "-Wunused-value"
#pragma GCC diagnostic ignored "-Wunused-result"
#pragma GCC diagnostic ignored "-Wunused-function"
// gcc 15 with sanitizers misattributes coroutine frame delete paths
#pragma GCC diagnostic ignored "-Wmismatched-new-delete"
#endif
#if defined(__clang__)
#pragma clang diagnostic ignored "-Wunused-lambda-capture"
#pragma clang diagnostic ignored "-Wunused-private-field"
#endif
#if defined(_MSC_VER)
#pragma warning(disable: 4834) // discarding [[nodiscard]] return value
#pragma warning(disable: 4189) // local variable initialized but not referenced
#pragma warning(disable: 4100) // unreferenced formal parameter
#pragma warning(disable: 4101) // unreferenced local variable
#pragma warning(disable: 4456) // declaration hides previous local declaration
#pragma warning(disable: 4457) // declaration hides function parameter
#pragma warning(disable: 4458) // declaration hides class member
#pragma warning(disable: 4459) // declaration hides global declaration
#endif

#include <iostream>
#include <thread>
#include <shared_mutex>
#include <vector>

std::shared_mutex rw_mutex;
std::vector<int> data;

void reader(int id)
{
    std::shared_lock<std::shared_mutex> lock(rw_mutex);  // shared access
    std::cout << "Reader " << id << " sees " << data.size() << " elements\n";
}

void writer(int value)
{
    std::unique_lock<std::shared_mutex> lock(rw_mutex);  // exclusive access
    data.push_back(value);
    std::cout << "Writer added " << value << "\n";
}

Lock Types

std::shared_lock

Acquires a shared lock—multiple threads can hold shared locks simultaneously.

std::unique_lock (on shared_mutex)

Acquires an exclusive lock—no other locks (shared or exclusive) can be held.

Behavior

  • While any reader holds a shared lock, writers must wait

  • While a writer holds an exclusive lock, everyone waits

  • Multiple readers can proceed simultaneously

This pattern maximizes concurrency for read-heavy workloads. Use std::shared_mutex when reads vastly outnumber writes.

Example: Thread-Safe Cache

#include <shared_mutex>
#include <unordered_map>
#include <string>
#include <optional>

class ThreadSafeCache
{
    std::unordered_map<std::string, std::string> cache_;
    mutable std::shared_mutex mutex_;

public:
    std::optional<std::string> get(std::string const& key) const
    {
        std::shared_lock lock(mutex_);  // readers can proceed in parallel
        auto it = cache_.find(key);
        if (it != cache_.end())
            return it->second;
        return std::nullopt;
    }

    void put(std::string const& key, std::string const& value)
    {
        std::unique_lock lock(mutex_);  // exclusive access for writing
        cache_[key] = value;
    }
};

Multiple threads can call get() simultaneously without blocking each other. Only put() requires exclusive access.