Table of Contents

This article explains the two semaphore types introduced in C++20: std::counting_semaphore and std::binary_semaphore. We’ll first use a counting semaphore to limit how many threads can operate at the same time. Then we’ll use a binary semaphore to send a signal between threads. We’ll also look at timed waiting, a small RAII helper, and a few more details.

Note: The synchronization features discussed here are available in C++20. The examples use C++23 std::println for cleaner output.

Let’s go.

Basics  

A mutex works well when only one thread should enter a protected section at a time. But sometimes that limit is too strict.

Imagine an application with three database connections. Running only one database operation at a time would waste two of them. On the other hand, allowing any number of threads to start an operation could overload the database.

What we need is a limit: three threads may continue, while the others wait.

There is another common case. One thread prepares some data, and another thread waits until the data is ready.

Semaphores work well for both problems.

A lot of multi-threading libraries have semaphores, but it’s pretty cool that the C++20 Standard Library now includes them right out of the box.

API  

A counting semaphore is declared as:

std::counting_semaphore<LeastMaxValue>

The main operations are:

Function Description
counting_semaphore(desired) Creates a semaphore with the counter set to desired
acquire() Decreases the counter, or waits if it is zero
release(update) Increases the counter by update; the default is 1
try_acquire() Tries once without waiting
try_acquire_for() Waits for a limited duration
try_acquire_until() Waits until a given time point
max() Returns the largest counter value supported by the implementation

std::binary_semaphore is an alias for:

std::counting_semaphore<1>

It is useful when one outstanding signal is enough.

Let’s start with the counting version.

Limiting concurrency with std::counting_semaphore  

Suppose we have eight jobs, but only three should perform an expensive operation at the same time:

#include <chrono>
#include <mutex>
#include <print>
#include <semaphore> // << new header!
#include <thread>
#include <vector>

int main() {
    constexpr int workerCount = 8;
    constexpr int slotCount = 3;

    std::counting_semaphore<slotCount> slots{slotCount};

    std::mutex outputMutex;
    int active = 0;

    auto worker = [&](int id) {
        slots.acquire();

        {
            std::lock_guard lock(outputMutex);
            ++active;
            std::println(
                "worker {} entered; active={}",
                id,
                active
            );
        }

        std::this_thread::sleep_for(
            std::chrono::milliseconds(250)
        );

        {
            std::lock_guard lock(outputMutex);
            --active;
            std::println(
                "worker {} leaving; active={}",
                id,
                active
            );
        }

        slots.release();
    };

    std::vector<std::jthread> threads;
    threads.reserve(workerCount);

    for (int i = 0; i < workerCount; ++i)
        threads.emplace_back(worker, i);
}

Run @Compiler Explorer

The semaphore starts with three available slots.

The first three workers call acquire() and continue. The internal counter then reaches zero. When a fourth worker calls acquire(), it has to wait.

Once one of the active workers finishes and calls release(), a slot becomes available again. One waiting worker can then continue.

One possible part of the output is:

worker 0 entered; active=1
worker 2 entered; active=2
worker 1 entered; active=3
worker 2 leaving; active=2
worker 4 entered; active=3

The order may change between runs, but active should never be greater than three.

The mutex in this example does not limit the number of workers. It only protects the diagnostic counter and keeps the output readable. The semaphore is the part that enforces the three-worker limit.

Logging can slightly change thread scheduling, but it does not change the rule enforced by the semaphore. The mutex is held only for a short print operation, not for the simulated work.

This is also why the code is not a traditional critical section. Three workers are allowed to run the operation together. We are limiting concurrency, not forcing complete mutual exclusion.

There is one more detail: the semaphore knows how many slots are available, but it does not know what those slots represent. If they stood for three real database connections, we would still need a separate container holding those connections.

Returning a slot with RAII  

The example above calls release() by hand. That works, but it is easy to make a mistake.

An early return or an exception between acquire() and release() could prevent the slot from being returned. Other threads might then wait forever, even though the underlying work has already stopped.

This is similar to calling lock() and forgetting to call unlock().

A small RAII guard can help:

template <std::ptrdiff_t LeastMaxValue>
class SemaphoreGuard {
public:
    explicit SemaphoreGuard(
        std::counting_semaphore<LeastMaxValue>& sem
    )
        : sem_(sem) {
        sem_.acquire();
    }

    ~SemaphoreGuard() {
        sem_.release();
    }

    SemaphoreGuard(const SemaphoreGuard&) = delete;
    SemaphoreGuard& operator=(
        const SemaphoreGuard&
    ) = delete;

private:
    std::counting_semaphore<LeastMaxValue>& sem_;
};

It can be used at the start of a scope:

SemaphoreGuard guard{slots};

The constructor waits for a slot, and the destructor returns it.

This works well when the slot represents reusable capacity: a database connection, an upload slot, a buffer, or access to limited hardware. On the other hand, it does not fit every signaling example. A one-way signal is often meant to be consumed rather than returned.

Signaling with std::binary_semaphore  

A mutex has ownership. The thread that locks it must also unlock it.

A semaphore does not have this rule. One thread can wait in acquire(), while another thread calls release().

That makes std::binary_semaphore useful for simple communication between threads.

In the next example, the worker waits until the main thread tells it to start. It calculates a result and then sends a signal back:

#include <print>
#include <semaphore>
#include <thread>

int main() {
    std::binary_semaphore startSignal{0};
    std::binary_semaphore doneSignal{0};

    int result = 0;

    std::jthread worker([&] {
        startSignal.acquire();

        result = 42;

        doneSignal.release();
    });

    std::println("[main] starting worker");
    startSignal.release();

    doneSignal.acquire();
    std::println("[main] result = {}", result);
}

Run @Compiler Explorer

Both semaphore counters start at zero.

The worker stops at startSignal.acquire(). The main thread calls startSignal.release(), which sends the start signal and lets the worker continue.

After writing result, the worker calls doneSignal.release(). The main thread waits for this signal before reading the result.

The semaphore also handles memory synchronization. The main thread sees the writes made by the worker before the release() that allowed doneSignal.acquire() to finish.

That means the main thread can safely read result after doneSignal.acquire() returns.

The signal is not returned afterward, and that is fine. It represented a one-time notification rather than a reusable slot.

Waiting with a timeout  

acquire() can wait forever. Some code cannot accept that.

For example, an application may want to report an error, retry the operation, or switch to a fallback path after waiting for too long.

try_acquire_for() waits for a relative duration:

#include <chrono>
#include <print>
#include <semaphore>
#include <thread>

int main() {
    std::counting_semaphore<1> sem{0};

    std::jthread notifier([&] {
        std::this_thread::sleep_for(
            std::chrono::milliseconds(200)
        );

        sem.release();
    });

    if (sem.try_acquire_for(
            std::chrono::milliseconds(100))) {
        std::println("First wait succeeded");
    }
    else {
        std::println("First wait timed out");
    }

    if (sem.try_acquire_for(
            std::chrono::milliseconds(300))) {
        std::println("Second wait succeeded");
    }
    else {
        std::println("Second wait timed out");
    }
}

See @Compiler Explorer

A typical result is:

First wait timed out
Second wait succeeded

The first call stops waiting before the notifier calls release(). It does not decrease the counter.

Later, the notifier increases the counter, and the second call succeeds.

A timeout is not an exact scheduling deadline. Waiting for 100 milliseconds means the function will not report a timeout before that duration has passed. The operating system may resume the thread a little later.

Semaphore, mutex, or condition variable?  

These tools solve different problems.

Use a mutex when one thread should own a protected section. The thread that locks the mutex must unlock it, and std::lock_guard makes that ownership easy to manage.

Use a counting semaphore when up to N operations may run at the same time.

Use a binary semaphore when one thread needs to send a simple signal to another thread.

A condition variable is a better fit when threads wait for shared state to satisfy a condition. It normally works together with a mutex and a predicate.

Tool Common use
std::mutex One thread owns a protected section
std::counting_semaphore Up to N operations run together
std::binary_semaphore One thread signals another
std::condition_variable Threads wait for a shared-state condition

A semaphore also remembers an unused counter value. If release() runs before another thread starts waiting, a later acquire() can still use that value.

With a condition variable, code normally checks shared state rather than relying on a stored notification.

Details worth knowing  

LeastMaxValue is a lower bound  

In:

std::counting_semaphore<3>

the value 3 means that the implementation must support a counter of at least three.

Its real maximum may be larger. max() returns the value supported by the implementation.

Application code should normally use the number it actually needs and not depend on any extra range offered by one standard library.

Do not increase the counter past max()  

release(update) requires that the new counter value does not exceed max().

This matters with std::binary_semaphore. It is not an event flag that can be set many times without being cleared. Calling release() twice without an acquire() between those calls may break the function’s precondition.

try_acquire() may fail spuriously  

try_acquire() performs one non-blocking attempt. It is allowed to return false even when the counter is greater than zero.

Use acquire() when the thread must wait. Use try_acquire_for() or try_acquire_until() when it should wait only for a limited time.

Waiting order is not guaranteed  

If several threads are waiting, the standard does not say that the oldest waiter must run first.

Code that needs strict first-in, first-out behavior requires an extra queue or scheduling layer.

Watch the lifetime  

Do not destroy a semaphore while another thread may still be using it.

All calls to acquire(), release(), and the timed functions must finish before the semaphore’s lifetime ends.

Summary  

std::counting_semaphore is useful when several threads may run an operation together, but their number must stay below a fixed limit.

std::binary_semaphore works well for simple signals between threads.

Use a mutex for normal exclusive access and a condition variable when threads wait for a shared-state condition.

The main thing is to be clear about what the counter means. It may represent free worker slots, connections, buffers, or a signal waiting to be received.

References  

Back to you

Do you use semaphores mainly for limiting concurrent work or for signaling? Have you tried C++20 or some third-party libraries?