Semaphores: A Powerful Synchronization Construct

April 23, 2026 · Luciano Muratore

Semaphores: A Powerful Synchronization Construct

A Semaphore is one of the most fundamental synchronization constructs in concurrent programming. Where a mutex allows exactly one thread to proceed at a time, a semaphore generalizes this idea: it allows up to N threads to operate simultaneously, blocking the rest until a slot becomes available.

Its implementation is surprisingly compact. Two methods are all it takes: Acquire() and Release().


The Implementation

class Semaphore {
public:
    Semaphore(int max_available) : max_available(max_available), taken_(0) {}

    void Acquire() {
        std::unique_lock<mutex> lock(mx_);
        while (taken_ == max_available) {
            cond_.wait(lock);
        }
        ++taken_;
    }

    void Release() {
        std::lock_guard<mutex> lock(mx_);
        --taken_;
        cond_.notify_all();
    }

private:
    int max_available;
    int taken_;
    std::mutex mx_;
    condition_variable cond_;
};

The state is minimal: max_available is the capacity—how many threads are allowed in at once—and taken_ is the current count of threads that have acquired the semaphore and not yet released it.


Release(): Lock and Notify

void Release() {
    std::lock_guard<mutex> lock(mx_);
    --taken_;
    cond_.notify_all();
}

Release uses lock_guard because the work here is short and unconditional: decrement taken_, then broadcast to all waiting threads. There is no need to release the lock early or wait on a condition—lock_guard acquires on construction and releases on destruction, which is exactly what we need.

Once taken_ is decremented, notify_all wakes every waiting thread. They will each recheck the condition in Acquire and, if a slot is now available, proceed.


Acquire(): Lock, Check, and Possibly Sleep

void Acquire() {
    std::unique_lock<mutex> lock(mx_);
    while (taken_ == max_available) {
        cond_.wait(lock);
    }
    ++taken_;
}

Acquire uses unique_lock because it needs to release the lock while sleeping. lock_guard cannot do this—it holds the lock for its entire lifetime. unique_lock, on the other hand, can temporarily release the lock when passed to cond_.wait, allowing other threads to call Release while this thread sleeps.

The while loop is essential. When notify_all wakes multiple threads at once, all of them compete to reacquire the lock. Only one proceeds at a time, and each must recheck whether a slot is actually available—not just assume it is because it was woken up. This guards against spurious wakeups as well.

Once the condition is satisfied—taken_ < max_available—the thread increments taken_ and proceeds.


Why unique_lock vs lock_guard?

The choice between the two is not arbitrary. It follows directly from what each method needs to do:

  • Release only needs to hold the lock briefly to modify state and signal. lock_guard is sufficient.
  • Acquire may need to sleep while holding no lock, then reacquire it upon waking. Only unique_lock supports this.

This distinction is the key design decision in the implementation.


Summary

  • A Semaphore generalizes mutual exclusion: it allows up to max_available threads to operate concurrently.
  • Acquire blocks a thread when the semaphore is saturated, using unique_lock so the lock can be released during sleep.
  • Release decrements the count and wakes all waiting threads, using lock_guard for a simple, scoped hold.
  • The while loop in Acquire guards against spurious wakeups and the race between multiple threads woken by notify_all.
  • Final Insight: The difference between lock_guard and unique_lock is not just a style choice—it reflects whether a thread needs to sleep and reawaken inside a critical section, or simply lock, act, and leave.