Synchronization Primitives

Synchronization Primitives

Coordinate threads with the right primitive instead of forcing every problem into a single mutex.

Synchronization Primitives

Start with the invariant

Before choosing a primitive, decide what you are protecting:

The primitive should match the coordination problem.

If the rule is unclear, the primitive choice will be unclear too. Start by writing one sentence such as "only one thread may mutate the cache at a time" or "at most eight workers may use the pool concurrently."

std::shared_mutex

Use this when reads are frequent and writes are rare.

std::shared_mutex cache_mutex;
std::unordered_map<std::string, int> cache;

int lookup(std::string_view key) {
    std::shared_lock lock(cache_mutex);
    return cache.at(std::string{key});
}

void update(std::string key, int value) {
    std::unique_lock lock(cache_mutex);
    cache[std::move(key)] = value;
}

This is useful for read-mostly structures, but it is not automatically faster than std::mutex. Measure contention before paying the extra complexity cost.

Semaphores

Use a semaphore when several tasks may proceed concurrently, but only up to a fixed limit.

std::counting_semaphore<8> db_slots(8);

This is often a better fit than a mutex when you are modeling capacity instead of exclusive ownership.

void use_slot() {
    db_slots.acquire();
    do_query();
    db_slots.release();
}

Latches and barriers

std::latch ready(3);

void worker() {
    prepare();
    ready.count_down();
    ready.wait();
    run();
}

Use std::barrier instead when the threads must meet again after each phase, not just once at startup.

Condition variables still matter

Condition variables remain the right tool when threads must sleep until a state predicate becomes true.

cv.wait(lock, [] { return ready; });

Condition variables remain the standard tool for "sleep until this state becomes true". Always make the state explicit and keep the wait predicate next to it.

Comparison guide

Worked coordination example

std::mutex queue_mutex;
std::condition_variable queue_cv;
std::queue<int> jobs;
std::counting_semaphore<2> worker_slots(2);
std::barrier phase_done(3);
bool done = false;

void worker() {
    while (true) {
        worker_slots.acquire();

        std::unique_lock lock(queue_mutex);
        queue_cv.wait(lock, [&] { return done || !jobs.empty(); });

        if (done && jobs.empty()) {
            worker_slots.release();
            break;
        }

        int job = jobs.front();
        jobs.pop();
        lock.unlock();

        process(job);
        worker_slots.release();
        phase_done.arrive_and_wait();
    }
}

This example is intentionally small, but it shows how the primitives differ:

Most real systems do not need all three at once. The point is to match each primitive to one rule instead of trying to force one primitive to model everything.

Common mistakes

Practical choice rule

Exercises

Example in practice

#include <thread>

int main() {
    std::jthread worker([] { /* do work */ });
    return 0;
}

Try this variation

Add a stop condition or notification path. Concurrency examples become useful only once shutdown and coordination are explicit.

#include <stop_token>
#include <thread>

int main() {
    std::jthread worker([](std::stop_token token) {
        while (!token.stop_requested()) {
            break;
        }
    });
}