Semaphores in C++: Cleaner Multi-Threading with Permits and Signaling
Move beyond mutexes. Learn the core principles of semaphores and how to deploy them using C++20.
Semaphores in C++: Cleaner Multi-Threading with Permits and Signaling
Move beyond mutexes. Learn the core principles of semaphores and how to deploy them using C++20.

Part-4 of C++ Multi-threading series.
In the Part-3 **Deadlock by Example: Dining Philosophers, Permits, and Condition Variables **of Multi-threading series:
- We successfully implemented the dining philosophers program!
- We used mutexes to model exclusive access to forks.
- We used a counter and a mutex to model permits that you must have before attempting to eat.
- We used a condition variable to allow threads returning permits to tell waiting threads there are permits available.
This “permission slip” pattern with signaling is a very common pattern:
- Have a counter, mutex and condition_variable_any to track some permission slips
- Thread-safe way to grant permission and to wait for permission (aka sleep)
- But, it’s cumbersome to need 3 variables to implement this — is there a better way?
A semaphore is a single variable type that encapsulates all these functionality.
In the next section, I’ll explain semaphores using the dining philosophers problem as the context. The C++20 semaphore section appears at the end of the post, so don’t confuse the custom semaphore implementation with the standard library one.
What is a Semaphore?
A semaphore manages a count of finite resources. Think of it as a permit dispenser:
- You initialize it with the starting resource count.
**wait()** — request a permit (blocks if count is 0 or negative).**signal()** — return a permit (wakes up waiting threads).
Note: count can be negative in our custom implementation! This allows for some interesting use cases (more later). A negative value of **-n means `n`** threads are currently blocked, waiting for signals.
class semaphore {
public:
semaphore(int value = 0);
void wait();
void signal();
private:
int value;
std::mutex m;
std::condition_variable_any cv;
}
How Semaphores Work — State Machine
This is the most important section. The semaphore’s behavior changes entirely based on whether its internal count is positive, zero, or negative.
The Three Zones:

What Happens Step by Step:

Semaphore Implementation
**signal() — Return a permit**
void semaphore::signal() {
m.lock();
value++;
if (value == 1) cv.notify_all(); // Wake up any waiting threads
m.unlock();
}
**wait() — Request a permit**
void semaphore::wait() {
m.lock();
cv.wait(m, [this] { return value > 0; }); // Sleep until value > 0
value--;
m.unlock();
}
Why
[this]? Lambdas capturing instance variables need**[this]to access them. Why loop /cv.waitwith predicate?** Multiple threads can be woken for a single permit — the predicate re-checks the condition after waking.
With unique_lock (cleaner version):
unique_lock is a RAII wrapper that automatically locks on creation and unlocks on destruction — very useful when a function has multiple return paths.
void semaphore::signal() {
unique_lock<mutex> lk(m); // Locks on entry
value++;
if (value == 1) cv.notify_all();
} // Unlocks automatically here
void semaphore::wait() {
unique_lock<mutex> lk(m); // Locks on entry
cv.wait(lk, [this] { return value > 0; });
value--;
} // Unlocks automatically here

Particularly useful with **condition_variable because `cv.wait()** requires a lockable object, andunique_lock` satisfies that cleanly.
The Dining Philosopher's implementation using Our Custom Semaphore
Here’s our final version of the dining-philosophers, replacing size_t, mutex, and condition_variable_any with a single semaphore.
static void philosopher(size_t id, mutex & left, mutex & right, semaphore & permits) {
for (size_t i = 0; i < kNumMeals; i++) {
think(id);
eat(id, left, right, permits);
}
}
int main(int argc,
const char * argv[]) {
// NEW
semaphore permits(kNumForks - 1);
mutex forks[kNumForks];
thread philosophers[kNumPhilosophers];
for (size_t i = 0; i < kNumPhilosophers; i++) {
mutex & left = forks[i];
mutex & right = forks[(i + 1) % kNumPhilosophers];
philosophers[i] = thread(philosopher, i, ref(left), ref(right), ref(permits));
}
for (thread & p: philosophers) p.join();
return 0;
}
eat now relies on the semaphore instead of calling waitForPermission and grantPermission.
static void eat(size_t id,
mutex & left,
mutex & right ,
semaphore & permits)
{
// NEW
permits.wait();
left.lock();
right.lock();
cout << id << " starts eating om nom nom nom." << endl << osunlock;
sleep_for(getEatTime());
cout << id << " all done eating." << endl << osunlock;
// NEW
permits.signal();
left.unlock();
right.unlock();
}
Semaphore Use Cases
There are three core patterns, each determined by the initial value of the semaphore.

Use Case 1: Permits
Initial value = N (positive)
Model N available resources. Threads consume permits with **wait() and return them with `signal()`**.

// Dining philosophers: allow at most (N-1) philosophers to eat at once
semaphore permits(kNumForks - 1);
static void eat(size_t id, mutex& left, mutex& right, semaphore& permits) {
permits.wait(); // Acquire a permit before picking up forks
left.lock();
right.lock();
// ... eat ...
permits.signal(); // Return the permit
left.unlock();
right.unlock();
}
Use Case 2: Binary Coordination
Initial value = 0
Thread A waits for Thread B to complete some work. The semaphore encodes the event status: **0 = not done yet, `1`** = done but not yet acknowledged.

semaphore zeroSemaphore(0); // No permits initially
void create(int count, semaphore& s) {
for (int i = 0; i < count; i++) {
cout << "Now creating " << i << endl;
s.signal(); // Signal: new item available!
}
}
void consume_after_create(int count, semaphore& s) {
for (int i = 0; i < count; i++) {
s.wait(); // Block until something is available
cout << "Now consuming " << i << endl;
}
}
Use Case 3: General Coordination
Initial value = -(N-1) or 0 with N waits
Thread A waits for N events to occur before proceeding. This generalizes **thread::join()**.
Option A: Initialize to **-(N-1). Thread A calls `wait()** once; each of the N threads callssignal()`. Only after all N signals does the count reach 1.
Option B: Initialize to **0. Thread A calls `wait()** N times; each of the N threads callssignal()` once.

semaphore negSemaphore(-9); // needs 10 signals to reach 1
void worker(int i, semaphore& s) {
cout << "Sending signal " << i << endl;
s.signal(); // Each worker signals once when done
}
void read_after_ten(semaphore& s) {
s.wait(); // Blocks until 10 signals have been received
cout << "Got enough signals to continue!" << endl;
}
int main() {
thread writers[10];
for (int i = 0; i < 10; i++)
writers[i] = thread(worker, i, ref(negSemaphore));
thread r(read_after_ten, ref(negSemaphore));
// ...
}
Multithreading Patterns Summary

Semaphore Vs Mutex — The Difference
The biggest mistake I see junior devs make when writing concurrent code is treating a binary semaphore and a mutex like they are the exact same thing. They aren’t. They solve entirely different system-level problems.
Mutexes are about ownership. Semaphores are about signaling and counting.
Here is a visual breakdown of how we look at them at the OS level, followed by the technical differences and code.

Mutex (Mutual Exclusion)
A mutex is a locking mechanism. You use it when you have a critical section of code where shared memory is being modified, and you absolutely cannot have two threads in there at the same time.
The Golden Rule: The thread that locks a mutex must be the thread that unlocks it. Because of this ownership model, the OS kernel can do smart things like Priority Inheritance. If a low-priority thread holds a mutex that a high-priority thread is waiting for, the OS will temporarily bump the low-priority thread’s priority up so it can finish and release the lock faster, preventing priority inversion.
#include <pthread.h>
// Statically initialize a mutex
pthread_mutex_t data_lock = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;
void* update_data(void* arg) {
// 1. Thread acquires ownership
pthread_mutex_lock(&data_lock);
// 2. Critical Section (safe from race conditions)
shared_counter++;
// 3. Thread releases ownership.
// Calling this from a different thread results in undefined behavior!
pthread_mutex_unlock(&data_lock);
return NULL;
}
Semaphore
A semaphore is a signaling mechanism built around a counter. It does not care about threads. It only cares about the integer it is guarding. There is no concept of “ownership”. Thread A can call **sem_wait() (decrementing the counter), and an entirely different Thread B can call `sem_post()`** (incrementing the counter).
We usually use them for two things:
- Counting: Regulating access to a pool of identical resources (e.g., “I have 5 database connections, track them”).
- Signaling/Execution Order: Making Thread B sleep until Thread A finishes a specific task.
#include <semaphore.h>
#include <pthread.h>
#include <stdio.h>
sem_t task_sem;
void* producer(void* arg) {
printf("Producer: Crunching numbers...\n");
// Simulate work...
// Increment the semaphore. This signals the consumer that data is ready.
sem_post(&task_sem);
return NULL;
}
void* consumer(void* arg) {
// Decrement the semaphore. If the count is 0, this thread goes to sleep
// until the producer calls sem_post().
sem_wait(&task_sem);
printf("Consumer: Producer is done, I can process the data now.\n");
return NULL;
}
int main() {
// Initialize semaphore with a value of 0 (0 means locked/not ready)
// 2nd arg '0' means shared between threads of this process
sem_init(&task_sem, 0, 0);
// ... thread creation logic omitted for brevity ...
sem_destroy(&task_sem);
return 0;
}
To summarise
If you need to protect a shared variable (**x = x + 1), use a mutex. If you need an event-driven system **where one thread unblocks another, or you're managing a bounded queue, use a semaphore.
Never use a binary semaphore just to protect a variable. It lacks priority inheritance, making your system vulnerable to priority inversion deadlocks — the exact bug that almost killed the Mars Pathfinder mission in ’97. Keep your tools doing what they were designed to do.
C++20 Semaphores
C++20 ships two semaphore types, and picking the right one matters:
#include <semaphore>
std::counting_semaphore<10> sem_a{0}; // max count = 10, starts at 0
std::binary_semaphore sem_b{1}; // alias for counting_semaphore<1>
A couple things worth pointing out:
- The template parameter is the maximum value (compile-time), not the initial one.
- The constructor argument is the initial count.
**binary_semaphoreis literally just `using binary_semaphore = counting_semaphore<1>**;` — nothing fancy.
The Four APIs You’ll Actually Use
That’s it. The whole interface is tiny:
sem.acquire(); // blocks until count > 0, then decrements
sem.release(); // increments count by 1, wakes a waiter
sem.release(n); // bump by n
sem.try_acquire(); // non-blocking, returns bool
sem.try_acquire_for(dur); // wait up to a duration
sem.try_acquire_until(tp); // wait until a time_point
No **wait(), no `signal()** — the committee pickedacquire/release` to match Dijkstra's P/V semantics in modern naming.
Important Points to Remember
- No owner. Unlike a mutex, any thread can
release(). That's a feature, not a bug — but it also means you don't get RAII for free. Wrap it yourself if you need scope safety. **release(n)must not exceed the max. Going over `LeastMaxValue`** is UB.- It’s not reentrant. Calling
**acquire()twice from the same thread on a `binary_semaphore`** will deadlock you. - Prefer semaphores over
condition_variablefor simple counting/signaling.

Closing Thoughts
Semaphores are deceptively simple: a counter with acquire and release operations. But once you see the three common initial states, they become a clean solution to a lot of concurrency problems.
The main takeaway is this: if you find yourself combining a mutex, a counter, and a condition variable just to manage permits or signaling, you’re probably rebuilding a semaphore. Use the right primitive instead of hand-rolling one.
Also remember the distinction: mutexes protect data, semaphores coordinate flow. They may look similar in examples, but their ownership rules and runtime behavior are very different.
Finally, the initial value is the design:
- Positive for resource pools.
- Zero for signaling.
- Negative, or multiple waits, for coordination barriers.
C++20 makes this practical and standard, so for simple counting and coordination problems, semaphores are usually the cleaner choice.
If you’re serious about passing technical interviews, try PracHub. It helped me structure my preparation with advanced mock sessions. [**Check it out here**] and start practicing. (Disclosure: This is an affiliate link).
Found this article helpful? Please Clap 👏 and follow for more C++ and system programming content.
메타데이터
- post_id
- 97722a8a39ce
- slug
- cpp20-semaphores-permits-signaling-97722a8a39ce
- url
- https://towardsdev.com/cpp20-semaphores-permits-signaling-97722a8a39ce
- canonical_url
- https://towardsdev.com/cpp20-semaphores-permits-signaling-97722a8a39ce
- author_url
- https://medium.com/@sagarmadala
- status
- ok
- fetched_at
- 2026-06-23 03:48:11