Concurrencysemaphorecounting semaphorebinary semaphoreconnection poolproducer consumer

Semaphores and Condition Variables

A semaphore is a counter with blocking decrement and non-blocking increment and no notion of an owner — the right tool for "at most N at once" and for signalling between threads — while a mutex owns and a condition variable waits for a predicate; the three are different primitives, not interchangeable spellings.

ConceptualC++CPythonUnix-style
▶ InteractiveInterview question
Progress

The problem

The database allows 10 connections. Forty request handlers want one. A mutex lets one through; you need ten. And when a handler finishes, one of the thirty waiting must be woken — without every one of them waking to fight for the slot.

A counter you can wait on

CPython

Dijkstra’s semaphore (1965) is an integer with two atomic operations. wait (P, acquire, down): if the count is greater than zero, decrement and continue; otherwise block until it is. signal (V, release, up): increment, and if anyone is blocked, wake one. A semaphore initialised to N admits N concurrent holders; initialised to 1 it admits one; initialised to 0 it admits nobody until someone signals — which makes it a pure signalling device.

There is no owner. Any thread may signal, including one that never called wait, including an interrupt handler. That is the feature: a producer signals a consumer, a completion signals a waiter, a worker releases a slot it did not itself take. It is also the reason a semaphore is the wrong tool for mutual exclusion — nothing stops a bug from signalling twice, the runtime cannot detect a double release or a release by a stranger, and priority inheritance is impossible because there is no holder to inherit to. Mutexes covers what ownership buys.

POSIX gives sem_init/sem_wait/sem_post (unnamed, in-process or in shared memory) and sem_open (named, across processes). C++20 has std::counting_semaphore<N> and std::binary_semaphore. Python has threading.Semaphore(n) and asyncio.Semaphore(n); Go idiomatically uses a buffered channel of capacity N as a semaphore; Java has java.util.concurrent.Semaphore. Under Linux all the thread-level ones are the futex word of Mutexes with a count instead of a bit.

Ten permits: at most ten handlers hold a connection at once
1import threading
2
3POOL_SIZE = 10
4permits = threading.Semaphore(POOL_SIZE)
5
6def handle_request(req):
7 with permits: # acquire: blocks if 10 are already inside
8 conn = pool.checkout() # guaranteed to succeed: at most 10 checked out
9 try:
10 return run_query(conn, req)
11 finally:
12 pool.checkin(conn) # release happens when the with-block exits

The connection pool: permits = 10

A database connection is a socket (File Descriptors), a server-side process or thread, and memory on both ends; the server caps them (max_connections, commonly 100) and each one you hold idle costs the server something. So the client keeps a pool of, say, 10 open connections and hands them out. The gate on the pool is a counting semaphore with 10 permits: a handler acquires a permit, checks out a connection, runs its queries, checks the connection back in and releases the permit. Forty concurrent handlers means ten working and thirty blocked in acquire, in order, without spinning.

The semaphore is the *admission* control; a mutex still guards the pool’s internal free-list for the microseconds it takes to pop or push a connection. Separating the two is the point: the mutex is held for nanoseconds, the permit for the whole query — milliseconds — and you never want a mutex held for milliseconds (Critical Sections). HikariCP, pgbouncer’s client side, SQLAlchemy’s QueuePool, and every serious HTTP client pool have exactly this structure.

What the pool does under saturation is a policy decision the semaphore exposes: block forever (acquire()), block with a timeout (acquire(timeout=2.0) → fail fast with a 503 rather than pile up), or try_acquire and shed load. Unbounded waiting is how a slow database turns into a thread pool full of blocked handlers and then into an outage of everything else the process does; a timeout on the permit is the cheapest circuit breaker there is.

Forty handlers, ten permits, one database
acquirecount = 0permit10 socketsrelease40 request handlersSemaphore count = 1030 blocked in acquirePool: 10 connections (mutex on the free-list)PostgreSQL max_connections = 100
UserLLMAgentToolDataDecisionHumanGuardrail

Producer / consumer with counting semaphores

A bounded buffer of N slots between producers and consumers is the textbook use of two counting semaphores plus a mutex. empty starts at N and counts free slots; full starts at 0 and counts filled ones. A producer wait(empty), locks the buffer, inserts, unlocks, signal(full). A consumer wait(full), locks, removes, unlocks, signal(empty). Producers block only when the buffer is full and consumers only when it is empty; the mutex protects the few instructions that touch the index and the array. Backpressure is the wait(empty): a fast producer is slowed to the consumer’s pace instead of growing memory without bound (What Happens When the Receiver Is Slow is the same idea in the network stack).

The ordering rule is the trap: take the semaphore *before* the mutex. A producer that locks the mutex first and then waits on empty holds the lock while sleeping, so the consumer that would free a slot cannot take the lock to do it — a deadlock with two primitives and one thread each. Kernel pipes (Pipes: A Kernel Buffer Between Two Processes), queue.Queue(maxsize=N) in Python, BlockingQueue in Java and Go’s buffered channels are all this pattern packaged.

Bounded buffer: two counting semaphores and one mutex
1empty = Semaphore(N) # free slots
2full = Semaphore(0) # filled slots
3m = Mutex()
4
5produce(item): consume():
6 wait(empty) wait(full)
7 lock(m) lock(m)
8 buf[in] = item; in = (in+1)%N item = buf[out]; out = (out+1)%N
9 unlock(m) unlock(m)
10 signal(full) signal(empty)
11 return item

Binary semaphore is not a mutex; condition variables are a third thing

C++

A semaphore initialised to 1 looks like a mutex and is not one. It has no owner, so it cannot check that the releaser is the acquirer, cannot support priority inheritance, cannot be recursive, and its typical use is different: a thread waits on it for *another* thread to signal that something happened — a handoff, not a guard. Use a mutex to protect state; use a binary semaphore to signal an event once. Confusing the two is a common interview trap (mutex-vs-semaphore-q) because the code looks identical and the failure modes do not.

The third primitive, the condition variable, exists because neither of the others expresses "wait until some predicate over shared state becomes true". A condvar is always paired with a mutex: wait(cv, m) atomically releases m and sleeps; when woken it re-acquires m and returns. The waiter checks the predicate *in a loop*, because wake-ups may be spurious and because another thread may have consumed the condition between the notify and the re-acquire. notify_one wakes one waiter; notify_all wakes all — which, with many waiters, is the thundering herd that a counting semaphore avoids by design. C++ std::condition_variable, Python threading.Condition, Java’s Object.wait/notify, pthreads’ pthread_cond_t: identical semantics everywhere.

The three compose. A semaphore can be built from a mutex and a condvar (count under the mutex, wait while zero); a bounded queue from a mutex and two condvars (not_empty, not_full); a thread pool from a queue and a condvar. Knowing which one you are actually expressing — exclusion, admission, or predicate — tells you which to reach for.

A condition variable waits for a predicate, always in a loop, always with the mutex
1std::mutex m;
2std::condition_variable not_empty;
3std::deque<Job> jobs;
4
5Job take() {
6 std::unique_lock<std::mutex> lk(m);
7 not_empty.wait(lk, [] { return !jobs.empty(); }); // loop: re-check after every wake
8 Job j = std::move(jobs.front()); jobs.pop_front();
9 return j; // lk unlocks here
10}
11void put(Job j) {
12 { std::lock_guard<std::mutex> lk(m); jobs.push_back(std::move(j)); }
13 not_empty.notify_one(); // wake one waiter, not all
14}

Rate limits, bulkheads, and the backend view

Most concurrency limits in a backend are semaphores in disguise. A bulkhead — at most 20 concurrent calls to the payments service so a slow dependency cannot absorb every thread — is a semaphore with 20 permits and a timeout. A token-bucket rate limiter is a semaphore that a timer thread refills at R permits per second, with a cap of B; acquire is the request and the count is the bucket. Concurrency limits in asyncio (Semaphore(50) around outbound HTTP), Go’s errgroup.SetLimit, Kubernetes’ per-pod connection limits, and nginx’s limit_conn are all the same primitive at different altitudes.

The design questions are always the same: how many permits (the dependency’s real capacity, not a guess), what to do when none are free (block, time out, shed), and whether the wait queue is fair. Get the first wrong and you either under-use the dependency or overwhelm it; the second wrong and a slow dependency propagates as a hung service; the third wrong and one client’s burst starves another’s steady trickle.

Key points

  • A semaphore is a counter with blocking wait and waking signal, and no owner. N permits = at most N concurrent holders; 0 permits = a pure signal.
  • The connection pool is the canonical use: permits = pool size, held for the duration of the query; a mutex separately guards the free-list for nanoseconds.
  • Producer/consumer = empty (N) + full (0) + a mutex; take the semaphore before the mutex or you deadlock while holding the lock.
  • A binary semaphore is not a mutex: no ownership, no error checking, no priority inheritance. Guard state with a mutex; signal events with a semaphore.
  • A condition variable waits for a predicate under a mutex, in a loop, and is the third primitive; the other two can be built from it.
  • Bulkheads, rate limiters and concurrency caps in backends are semaphores with a timeout policy.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why a counter instead of N mutexes?

Because the caller does not care *which* slot it gets, only that one is free. N mutexes would force the caller to probe them; a counter lets the kernel wake exactly one waiter when any slot frees, in one operation.

Why does the semaphore have no owner?

So that the thread that frees a resource need not be the thread that took it — a consumer signals a producer, a completion handler signals a waiter, a worker returns a slot on behalf of another. Ownership would make the primitive useless for signalling.

Why do condition variables need a loop around the wait?

Because the OS may wake a waiter spuriously, and because between notify and the waiter re-acquiring the mutex another thread can consume the condition. The predicate, not the wake-up, is the truth; the loop re-checks it.

Semaphore connection pool

Semaphore: a database connection pool
12 requests arrive over ~180 ms with seeded service times. A counting semaphore hands out N permits; each permit is one open connection.
Permits available
2 / 3
Waiting queue
Active connections
#1
Database load (capacity 4 concurrent queries at full speed)1 conns
req 1 acquired a connection (waited 0 ms)
Completed
0 / 12
Throughput so far
0 req/s
p95 wait for a permit
0 µs
Finish time (this config)
210 ms
Ownership vs counting. A mutex has an owner: only the locker may unlock, and it protects data. A semaphore counts: any thread may post, and it rations a resource. Permits = 1 looks like a mutex but is not one — there is no owner to check, no priority inheritance.
1/21 · 0 msSimulated

How it fails

What the failure looks like from inside real software.

  • Pool exhausted with no timeout: every request handler blocks in acquire, the thread pool fills, and an unrelated health check fails — a slow database becomes a full outage.
  • Permit released twice on an error path; the pool now admits 11, then 12, then the database rejects connections with "too many clients".
  • Producer locks the mutex and then waits on empty; the consumer cannot take the mutex to free a slot; both hang.
  • A binary semaphore used as a mutex is released by the wrong thread after a refactor; two threads enter the critical section and the corruption is blamed on the data structure.
  • notify_all on a queue with 500 waiting workers wakes all 500 for one job; CPU spikes and 499 go back to sleep (thundering herd).
  • A condvar wait without a loop returns on a spurious wake-up; the consumer pops from an empty queue.