Skip to content
C++
Library
since C++20
Intermediate

Semaphore, Latch, and Barrier (C++20)

C++20 counting_semaphore, binary_semaphore, latch, and barrier — efficient thread synchronization primitives with defined happens-before semantics.

std::latch / std::barrier / std::counting_semaphoresince C++20

Three lightweight synchronization primitives — std::latch (one-shot countdown), std::barrier (reusable phase rendezvous), and std::counting_semaphore (bounded permit pool) — each more efficient than std::mutex + std::condition_variable for their respective coordination patterns.

Overview

C++20 introduces three purpose-built synchronization primitives that cover common coordination patterns previously requiring manual mutex/condvar combinations. Each has a distinct behavioral contract:

  • std::latch (<latch>): a countdown counter that reaches zero exactly once. All threads blocking on wait() unblock permanently when the count hits zero. Non-reusable.
  • std::barrier (<barrier>): a reusable rendezvous for a fixed set of threads. After every participant arrives, an optional completion callback runs in one of the arriving threads, then all participants are released for the next phase.
  • std::counting_semaphore / std::binary_semaphore (<semaphore>): a permit counter. Threads block when the count is zero and proceed after acquiring a permit. Unlike std::mutex, semaphores carry no ownership — any thread may call release().

All three guarantee happens-before semantics: side effects visible before the signal operation (count_down, arrive, release) are visible to threads that observe the signal (wait, acquire).

Syntax

cpp
#include <latch>
#include <barrier>
#include <semaphore>

// --- std::latch (C++20) ---
std::latch gate{N};
gate.count_down(n = 1);        // decrement by n (does not block)
gate.arrive_and_wait(n = 1);   // count_down(n) then block until count == 0
gate.wait();                   // block until count == 0
bool done = gate.try_wait();   // non-blocking: returns true if count == 0

// --- std::barrier (C++20) ---
std::barrier sync{N};                          // N participants, no callback
std::barrier sync{N, []() noexcept { ... }};  // completion callback — MUST be noexcept

auto token = sync.arrive();       // decrement arrival count; return opaque phase token
sync.wait(std::move(token));      // block until the phase corresponding to token ends
sync.arrive_and_wait();           // arrive() + wait() combined (most common)
sync.arrive_and_drop();           // arrive, then permanently remove self from future phases

// --- std::counting_semaphore<LeastMaxValue> (C++20) ---
std::counting_semaphore<16> sem{initial};  // template arg is a lower-bound hint on max value
std::binary_semaphore flag{0};             // alias for counting_semaphore<1>

sem.acquire();                            // block until count > 0, then decrement
sem.release(n = 1);                       // increment by n, unblock waiters
bool ok = sem.try_acquire();             // non-blocking; returns false if count == 0
bool ok = sem.try_acquire_for(dur);      // timed variant
bool ok = sem.try_acquire_until(tp);     // timed variant

The LeastMaxValue template parameter on counting_semaphore is a lower bound: the implementation may support larger values but may optimize storage when the hint is small. std::binary_semaphore allows reduction to a single bit of storage.

Examples

One-shot startup gate with std::latch

Workers initialize themselves and signal readiness; the main thread proceeds only after all are ready.

cpp
#include <latch>
#include <thread>
#include <vector>

void launch_workers(int n) {
    std::latch ready{n};
    std::vector<std::jthread> workers;
    workers.reserve(n);

    for (int i = 0; i < n; ++i) {
        workers.emplace_back([&, i] {
            load_thread_local_state(i);  // expensive per-thread init
            ready.count_down();          // signal: I'm ready (does not block)
            run_event_loop(i);           // proceeds immediately
        });
    }

    ready.wait();  // main blocks until all n workers counted down
    start_accepting_requests();
}  // jthread destructors join

Everything a worker does before count_down() is visible to the main thread after ready.wait() returns — the happens-before guarantee makes this safe without additional synchronization.

Iterative parallel algorithm with std::barrier

The barrier completion callback is ideal for inter-phase state transitions: it runs exactly once after all threads arrive and before any thread proceeds.

cpp
#include <barrier>
#include <thread>
#include <vector>

void jacobi_iterate(std::vector<double>& u, int rows, int cols, int iters) {
    std::vector<double> v(u);  // scratch buffer
    const int n = static_cast<int>(std::thread::hardware_concurrency());

    // Callback runs after all threads finish a sweep — safe to swap buffers here
    std::barrier sync{n, [&]() noexcept { u.swap(v); }};

    auto worker = [&](int tid) {
        const int lo = 1 + tid * ((rows - 2) / n);  // skip boundary rows
        const int hi = lo + (rows - 2) / n;

        for (int iter = 0; iter < iters; ++iter) {
            // Read from u (previous values), write to v (new values)
            for (int r = lo; r < hi; ++r)
                for (int c = 1; c < cols - 1; ++c)
                    v[r * cols + c] = 0.25 * (
                        u[(r-1)*cols+c] + u[(r+1)*cols+c] +
                        u[r*cols+c-1]  + u[r*cols+c+1]);

            sync.arrive_and_wait();
            // After this point: callback swapped u and v
            // u now holds the values just computed; next iter reads them
        }
    };

    std::vector<std::jthread> threads;
    for (int t = 0; t < n; ++t)
        threads.emplace_back(worker, t);
}

The buffer swap inside the callback is sequenced with respect to all threads — workers see the swapped state immediately after arrive_and_wait() returns.

Resource pool with std::counting_semaphore

cpp
#include <semaphore>

template<int MaxConns>
struct ConnectionPool {
    std::counting_semaphore<MaxConns> slots;

    explicit ConnectionPool(int initial) : slots{initial} {}

    template<typename F>
    auto with_connection(F&& fn) {
        slots.acquire();
        struct Guard {
            std::counting_semaphore<MaxConns>& s;
            ~Guard() { s.release(); }
        } g{slots};
        return std::forward<F>(fn)(checkout_connection());
    }
};

ConnectionPool<8> db_pool{3};  // at most 3 concurrent queries; storage hint: 8 max

Ping-pong signaling with std::binary_semaphore

cpp
#include <semaphore>
#include <string>

std::string shared_item;
std::binary_semaphore produced{0};  // consumer waits on this; starts unsignaled
std::binary_semaphore consumed{1};  // producer waits on this; starts signaled (slot free)

std::jthread producer([] {
    for (auto& item : data_source()) {
        consumed.acquire();  // wait for consumer to clear the slot
        shared_item = item;
        produced.release();  // notify consumer
    }
});

std::jthread consumer([] {
    while (has_more()) {
        produced.acquire();  // wait for producer
        process(shared_item);
        consumed.release();  // notify producer
    }
});

Two semaphores implement single-slot producer-consumer with back-pressure. No mutex, no condition variable, no spurious wakeups.

Best Practices

Use latch for one-shot startup, not for repeated synchronization. std::latch is non-reusable by design — once the count reaches zero it stays there permanently. For repeated synchronization between rounds, allocate a fresh latch per round or switch to barrier.

Put inter-phase state transitions in the barrier completion callback. It executes after all arrivals and before any thread proceeds, providing a sequenced window to swap buffers, advance phase counters, or update shared state — without additional locking or explicit coordination.

Always wrap semaphore acquisition in RAII. The standard provides no scope guard for semaphores. A bare acquire()/release() pair is exception-unsafe. Use a guard struct (as shown above) or a custom wrapper type.

Use arrive_and_drop() when a thread finishes early. In workloads where some threads complete their share before others, arrive_and_drop() permanently removes that thread from the expected count for all future phases. Without it, the remaining threads will block forever at the next barrier.

Prefer binary_semaphore over mutex for producer-consumer signaling. Semaphores allow release() from a different thread than acquire(), which mutexes do not. For one-directional notification (producer signals consumer), binary_semaphore is the right tool.

Common Pitfalls

Decrementing a latch below zero is undefined behavior. Calling count_down(n) when the internal count is less than n is UB. If multiple code paths can call count_down, guard against over-decrement — the standard provides no diagnostic.

Barrier completion callbacks must be noexcept. Any exception thrown from the callback propagates to std::terminate. Wrap fallible logic in try/catch and stash the exception for later inspection.

Semaphores are not mutexes. Any thread can call release(), even one that never called acquire(). Calling release(n) when the count would exceed LeastMaxValue is undefined behavior. Semaphores provide no recursive-acquire, no priority-inversion protection, and no ownership tracking.

Don't silently discard arrive()'s return value. The opaque arrival_token is the only way to later synchronize with that phase via wait(token). Discarding it means the calling thread never synchronizes with phase completion — almost always a bug. If you want fire-and-forget arrival, use arrive_and_wait() and document the intent.

std::latch and std::barrier are neither copyable nor movable. Manage them via reference, pointer, or std::shared_ptr. Attempting to pass or return by value will not compile.

counting_semaphore in Depth

Template parameter: LeastMaxValue is a hint, not a cap

std::counting_semaphore<LeastMaxValue> is a class template where LeastMaxValue is a compile-time lower bound on the maximum internal count, not the initial count and not a hard ceiling enforced at runtime. The initial count is supplied at construction. The standard guarantees max() >= LeastMaxValue; the implementation may allow higher values when the hardware can represent them cheaply.

The hint exists so that implementations can choose storage appropriate to the range. On most platforms, binary_semaphore (alias for counting_semaphore<1>) can fit into a single byte or even a CPU word flag, avoiding a full kernel semaphore object. Larger LeastMaxValue values may require a wider counter.

cpp
#include <semaphore>
#include <cassert>

// LeastMaxValue = 4: hint that 4 permits is the expected maximum.
// Actual max() may be larger (implementation-defined).
std::counting_semaphore<4> sem{4};   // start with 4 permits available
assert(sem.max() >= 4);              // guaranteed by the standard

// binary_semaphore is exactly counting_semaphore<1>
std::binary_semaphore flag{0};       // initially locked (0 permits)
flag.release();                      // now 1 permit available
flag.acquire();                      // consumes the permit; back to 0

Calling release(n) when the count would exceed max() is undefined behavior. Track the maximum outstanding releases yourself if you are building a bounded pool.

Full semaphore API

cpp
#include <semaphore>
#include <chrono>

std::counting_semaphore<8> sem{3};   // 3 of 8 slots in use

// --- Blocking acquire ---
sem.acquire();           // block until count > 0, then decrement atomically

// --- Non-blocking forms ---
bool ok1 = sem.try_acquire();                              // returns false immediately if count == 0
bool ok2 = sem.try_acquire_for(std::chrono::milliseconds{100});  // timeout after 100 ms
bool ok3 = sem.try_acquire_until(std::chrono::steady_clock::now()
                                 + std::chrono::seconds{1});    // absolute deadline

// --- Release ---
sem.release();     // increment count by 1, unblock one waiter if any
sem.release(3);    // increment by 3, unblock up to 3 waiters

try_acquire_for and try_acquire_until are the preferred alternatives to a spin loop — they park the calling thread in the OS until either a permit becomes available or the deadline expires.

Rate limiter pattern

A counting semaphore is a natural fit for rate-limiting concurrent access to a shared resource:

cpp
#include <semaphore>
#include <functional>

// Allow at most MaxConcurrent tasks to be in-flight simultaneously.
template<int MaxConcurrent>
class RateLimiter {
    std::counting_semaphore<MaxConcurrent> permits_;

public:
    explicit RateLimiter(int initial_permits)
        : permits_{initial_permits} {}

    // RAII handle: acquires a permit on construction, releases on destruction.
    struct Permit {
        std::counting_semaphore<MaxConcurrent>& sem;
        ~Permit() { sem.release(); }
    };

    [[nodiscard]] Permit acquire() {
        permits_.acquire();    // blocks if all slots are taken
        return Permit{permits_};
    }
};

RateLimiter<16> api_limiter{4};  // 4 concurrent calls; hint: 16 theoretical max

void call_external_api() {
    auto permit = api_limiter.acquire();  // blocks past 4 concurrent callers
    // ... HTTP request ...
}   // permit destructor releases slot automatically

The RAII Permit wrapper ensures the slot is always released even if an exception is thrown, exactly mirroring the mutex + lock_guard idiom.

latch vs. barrier: Choosing the Right Primitive

The key distinction is reusability:

Propertystd::latchstd::barrier
Reusable after reaching zeroNo — stays released foreverYes — resets each phase
Thread participation countFixed at constructionFixed at construction, adjustable via arrive_and_drop()
Completion callbackNoneOptional CompletionFunction per phase
Header<latch><barrier>
Typical useOne-time startup gate, fan-in waitIterative parallel algorithms, pipeline stages

Use std::latch when the synchronization point happens exactly once: all workers finish initialization, all tasks are submitted to a queue, all results are written. Once wait() returns the first time, it returns immediately on every subsequent call — the latch never "closes" again.

Use std::barrier when a fixed group of threads needs to rendezvous repeatedly — at the end of each iteration of a parallel loop, at each pipeline stage boundary, or each rendering frame. The barrier automatically resets after every phase.

latch::arrive_and_wait() vs. count_down() + wait()

std::latch provides two ways to contribute to the count-down and optionally wait:

cpp
#include <latch>

std::latch gate{8};

// Option 1: count_down() then wait() separately
// Use when the thread wants to signal readiness but continue doing
// other work before blocking, or when multiple threads share a single
// wait() point.
gate.count_down();   // decrement by 1 (non-blocking); does not wait
// ... do more work ...
gate.wait();         // block until count == 0

// Option 2: arrive_and_wait() — the combined form
// Equivalent to count_down() followed immediately by wait().
// Most concise when the thread has nothing else to do before blocking.
gate.arrive_and_wait();   // decrement and block until count == 0

count_down(n) accepts a decrement value greater than one, which enables the fan-out initialization pattern: a single coordinating thread counts down by the full number of tasks it just submitted, while worker threads call wait() to unblock only after the entire batch is queued.

cpp
#include <latch>
#include <thread>
#include <vector>

// Fan-out: coordinator submits N tasks with one count_down(N),
// then all workers wait until the batch is fully submitted before starting.
void fan_out_example(int n) {
    std::latch batch_ready{n};
    std::vector<std::jthread> workers;

    for (int i = 0; i < n; ++i) {
        workers.emplace_back([&] {
            batch_ready.wait();    // block until all tasks are queued
            pull_and_process();
        });
    }

    enqueue_tasks(n);
    batch_ready.count_down(n);   // release all workers in one shot
}

barrier: CompletionFunction and arrive_and_drop()

How the completion function works

The CompletionFunction passed to std::barrier is invoked by exactly one thread — whichever thread delivers the final arrival that completes the phase. The invocation happens after all arrivals are registered but before any waiting thread is released. This gives the callback a sequenced window to perform inter-phase work without any additional locking.

cpp
#include <barrier>
#include <vector>
#include <algorithm>

std::vector<double> current, scratch;

// The callback signature must be: void() noexcept
// It must not call any member function of the barrier it belongs to.
auto phase_transition = [&]() noexcept {
    // Everything every thread wrote before arrive_and_wait() is
    // visible here. Everything written here is visible to all
    // threads once they resume from wait().
    std::swap(current, scratch);   // ping-pong buffers
};

std::barrier sync{num_threads, phase_transition};

Rules for the completion function:

  • It must be noexcept. Any propagating exception terminates the program via std::terminate.
  • It must not call arrive(), arrive_and_wait(), arrive_and_drop(), or wait() on the same barrier — that is undefined behavior.
  • It may freely access shared data because it runs in a happens-before window: all thread side effects from the phase are visible, and all its own writes will be visible to all threads when they resume.
  • It is move-constructed into the barrier at construction time; the original callable must be movable.

arrive_and_drop(): removing a thread from future phases

When a thread finishes its share of work before the algorithm terminates, it must not call arrive_and_wait() in future phases — it will never reach the next barrier call and the other threads will block forever. The correct remedy is arrive_and_drop():

cpp
#include <barrier>
#include <vector>

void parallel_reduction(std::vector<double>& data, int num_threads) {
    // Each phase halves the active thread count (tree reduction).
    std::barrier sync{num_threads, []() noexcept {}};

    auto worker = [&](int tid) {
        int active = num_threads;
        int stride = 1;

        while (active > 1) {
            if (tid % (stride * 2) == 0 && tid + stride < active) {
                // This thread is still active: accumulate a neighbor's result.
                data[tid] += data[tid + stride];
                sync.arrive_and_wait();   // participate in this phase and the next
            } else {
                // This thread is done: signal arrival and exit the barrier group.
                sync.arrive_and_drop();   // contribute to this phase's count,
                return;                  // but reduce the expected count for all future phases
            }
            stride *= 2;
            active = (active + 1) / 2;
        }
    };

    std::vector<std::jthread> threads;
    for (int i = 0; i < num_threads; ++i)
        threads.emplace_back(worker, i);
}

After arrive_and_drop(), the expected participant count for all subsequent phases is permanently decremented by one. The calling thread must not touch the barrier again. arrive_and_drop() is also the correct pattern for early-exit scenarios: if a thread detects a termination condition mid-algorithm, it can drop out of the barrier group without blocking anyone.

Comparison: condition_variable vs. semaphore vs. latch vs. barrier

The four major C++ synchronization mechanisms differ along several dimensions. Choose based on your coordination direction and whether the state needs to be reused:

PrimitiveDirectionReusableThread pairingOwns a mutexTypical use case
condition_variableN:M (any thread notifies, any thread waits)YesNo fixed pairingYes (requires unique_lock)Waiting on an arbitrary predicate that changes over time
counting_semaphoreM:N (any thread releases, any thread acquires)YesNo fixed pairingNoBounding concurrent access; producer-consumer signaling across thread boundaries
latchN:1+ (N threads count down, any thread waits)No — single useNo fixed pairingNoOne-time startup gate; fan-in after parallel initialization
barrierN:N (all N threads arrive and all are released)Yes — per phaseFixed N participantsNoIterative parallel algorithms; pipeline stage rendezvous with optional inter-phase callback

Key differentiators worth memorizing:

  • condition_variable is the only one that requires a mutex; it is also the most general but the most verbose.
  • semaphore is the only one where the releasing thread need not be one of the acquiring threads — it is purely a permit counter with no ownership or thread identity.
  • latch is strictly simpler than barrier: no callback, no reset, no arrive_and_drop(). If you do not need reuse, prefer latch — the interface is smaller and the intent is clearer.
  • barrier is the only primitive that enforces a fixed participant set: every phase must have the same N arrivals (minus any drops). If the set of participating threads changes dynamically, a condition_variable + counter is more appropriate.

Performance note: All three C++20 primitives (latch, barrier, counting_semaphore) are designed to use OS-level blocking (futex on Linux, WaitOnAddress on Windows) rather than spin-waiting, making them efficient when threads genuinely need to park. They also guarantee no spurious wakeups, which condition_variable does not.

See Also