← Back to list

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.

Sagar in Towards Dev · 2026-05-18 08:30 · 11 claps · 8.7 min read
#programming #software-development #cpp20 #c-plus-plus-language #linux-system-programming
Open on Medium ↗
Wiki topics: 💻 · Programming 🔓 · Open Source 📚 · Books & Reading

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.wait with 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:

  1. Counting: Regulating access to a pool of identical resources (e.g., “I have 5 database connections, track them”).
  2. 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_semaphore is 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_variable for 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