Cached at:
08/08/26, 10:38 PM
# Understanding std::counting_semaphore and std::binary_semaphore from C++20
Source: [https://www.cppstories.com/2026/semaphore/](https://www.cppstories.com/2026/semaphore/)

Table of Contents
- [Basics](https://www.cppstories.com/2026/semaphore/#basics)
- [API](https://www.cppstories.com/2026/semaphore/#api)
- [Limiting concurrency with`std::counting\_semaphore`](https://www.cppstories.com/2026/semaphore/#limiting-concurrency-with-stdcounting_semaphore)
- [Returning a slot with RAII](https://www.cppstories.com/2026/semaphore/#returning-a-slot-with-raii)
- [Signaling with`std::binary\_semaphore`](https://www.cppstories.com/2026/semaphore/#signaling-with-stdbinary_semaphore)
- [Waiting with a timeout](https://www.cppstories.com/2026/semaphore/#waiting-with-a-timeout)
- [Semaphore, mutex, or condition variable?](https://www.cppstories.com/2026/semaphore/#semaphore-mutex-or-condition-variable)
- [Details worth knowing](https://www.cppstories.com/2026/semaphore/#details-worth-knowing)- [`LeastMaxValue`is a lower bound](https://www.cppstories.com/2026/semaphore/#leastmaxvalue-is-a-lower-bound) - [Do not increase the counter past`max\(\)`](https://www.cppstories.com/2026/semaphore/#do-not-increase-the-counter-past-max) - [`try\_acquire\(\)`may fail spuriously](https://www.cppstories.com/2026/semaphore/#try_acquire-may-fail-spuriously) - [Waiting order is not guaranteed](https://www.cppstories.com/2026/semaphore/#waiting-order-is-not-guaranteed) - [Watch the lifetime](https://www.cppstories.com/2026/semaphore/#watch-the-lifetime)
- [Summary](https://www.cppstories.com/2026/semaphore/#summary)
- [References](https://www.cppstories.com/2026/semaphore/#references)
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:
FunctionDescription`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](https://godbolt.org/z/WhfMEGKjn)
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](https://godbolt.org/z/K41r7ozzc)
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](https://godbolt.org/z/qccnn3TY7)
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\.
ToolCommon 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 conditionA 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
- [std::counting\_semaphore and std::binary\_semaphore \- cppreference](https://en.cppreference.com/w/cpp/thread/counting_semaphore)
- [C\+\+ working draft: semaphores](https://eel.is/c++draft/thread.sema)
- [Implementing C\+\+20 semaphores \- Red Hat Developer](https://developers.redhat.com/articles/2023/04/18/implementing-c20-semaphores)
- [C\+\+ Concurrency in Action, Second Edition](https://amzn.to/45c9Hmb)by Anthony Williams
- [Concurrency with Modern C\+\+](https://amzn.to/4bF4XsO)by Rainer Grimm
#### 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?