Understanding std:counting_semaphore and std:binary_semaphore from C++20

jandeboevrie1 pts0 comments

Understanding std::counting_semaphore and std::binary_semaphore from C++20 - C++ Stories

Last Update: 08 August 2026

Understanding std::counting_semaphore and std::binary_semaphore from C++20

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

The main operations are:

Function<br>Description

counting_semaphore(desired)<br>Creates a semaphore with the counter set to desired

acquire()<br>Decreases the counter, or waits if it is zero

release(update)<br>Increases the counter by update; the default is 1

try_acquire()<br>Tries once without waiting

try_acquire_for()<br>Waits for a limited duration

try_acquire_until()<br>Waits until a given time point

max()<br>Returns the largest counter value supported by the implementation

std::binary_semaphore is an alias for:

std::counting_semaphore

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<br>#include<br>#include<br>#include //<br>#include<br>#include

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

std::counting_semaphoreslotCount> slots{slotCount};

std::mutex outputMutex;<br>int active = 0;

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

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

std::this_thread::sleep_for(<br>std::chrono::milliseconds(250)<br>);

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

slots.release();<br>};

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

for (int i = 0; i workerCount; ++i)<br>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<br>worker 2 entered; active=2<br>worker 1 entered; active=3<br>worker 2 leaving; active=2<br>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><br>class SemaphoreGuard {<br>public:<br>explicit SemaphoreGuard(<br>std::counting_semaphoreLeastMaxValue>& sem<br>:...

active worker semaphore three counting_semaphore threads

Related Articles