Skip to content
ComputingAmerica

Engineering

The count is the contract: what semaphores still teach

Nearly every synchronization pattern is the same two operations with a different starting number. Learning that changes how you read concurrent code for good.

5 min readComputing America

In short

  • Almost every classical synchronization pattern is the same primitive with a different initial value: one for mutual exclusion, N for bounded concurrency, zero for waiting on a signal. That integer is the entire policy.
  • Signal then wait, never wait then signal. Most self-inflicted deadlocks come from inverting that order against a counterpart that has not been told to send anything yet.
  • A barrier built from the obvious pieces is correct once and broken on reuse. The two-phase turnstile is the standard fix and the reason it exists is worth understanding before you need it.
  • Implementing the same pattern in a forgiving language and an unforgiving one is a forcing function, because the unforgiving one refuses to compile the version you only thought you understood.
  • Semaphores are rarely the right tool in production code, but every higher-level primitive is a count, a wait and a signal underneath, and knowing that makes the rest legible.

Our engineers worked through Downey’s Little Book of Semaphores end to end and implemented every pattern in it twice, once in a language where the primitive is forgiving and once against the POSIX interface where it is not. Rendezvous, mutual exclusion, bounded concurrency, single-use and reusable barriers, the pre-loaded turnstile, the leader and follower queue, and the exclusive variant.

It is an odd thing for a commercial software firm to spend time on, and we would defend it on one ground: it is the shortest path we know to reading concurrent code accurately, and reading concurrent code accurately is what separates a plausible diagnosis of a production race from a correct one.

The primitive, and what makes it interesting

A semaphore is an integer with two operations. One decrements it and blocks while it would go negative. The other increments it and wakes a waiter if any are blocked. That is the entire interface, and the surprise is how much policy fits inside the choice of starting value.

Initial countWhat you haveWhat it is in production
1Mutual exclusion: exactly one participant inside at a time.A lock, under whatever name your runtime gives it.
NBounded concurrency: at most N inside at once.A connection pool, a rate limiter, a licence count, a worker cap.
0A signal: whoever arrives first waits until somebody else has done the thing.A latch, a completion handle, one half of a rendezvous.
One integer, three coordination contracts. Nothing else about the code changes between these rows.

The same two operations, three completely different coordination contracts, distinguished by one integer. Once you can see that integer as the policy rather than as an implementation detail, a great deal of concurrent code becomes readable that previously had to be taken on trust.

Below, every pattern in the book runs on one primitive. Switch between them and the only thing that changes is the starting count and where the waits sit; the deadlock scenario is the same code with two of them inverted.

Interactive · Concurrency

The classical patterns, with threads crossing the line

Live · Semaphore visualizer
Same primitive, six protocols. Switch modes to watch the same acquire / release pair become a mutex, a multiplex, a barrier, a bounded buffer, or, wired wrong, a deadlock.
critical section · capacity 3wait queuepermits: 3 / 3queued: 0completed: 0

Multiplex.Same primitive, initialised to N. Up to N threads in the section at once; arrivals beyond N block. Connection pools, rate limits, "at most K of these can run in parallel."

What this isRendezvous, mutex, bounded concurrency, a reusable barrier, a bounded buffer and a deliberate deadlock, each driven by the same counter-and-queue primitive the implementations use. The primitive never changes; what changes is the protocol built on it, which is the whole argument of the write-up.

The mistake that produces most self-inflicted deadlocks

Signal, then wait. Never wait, then signal. Roughly half the deadlocks introduced while learning these patterns come from inverting that order, and the reason is easy to state: the participant you are coordinating with cannot wake you on something it has not yet been told to send. Both threads sit in the wait, and each is waiting for a signal the other will send immediately after it stops waiting.

Written out, it is obvious. In code, with the two halves of the pattern a hundred lines apart in different files, it is not obvious at all, and it is a recognizable shape rather than a puzzle to be solved fresh each time.

python/rendezvous.pyPython
aArrived = Semaphore(0)bArrived = Semaphore(0) def A():    print("Drone A: Picked up the package.")    bArrived.release()       # tell B I’m here    aArrived.acquire()       # wait for B def B():    bArrived.acquire()       # wait for A    print("Drone B: Arrived at the rendezvous point.")    aArrived.release()       # tell A I’m here
Two counts starting at zero, four operations, and the entire ordering guarantee is the order of the two on lines 7 and 8. Invert them and A waits for a signal B only sends after it stops waiting, which is the same deadlock in both directions and roughly half of the ones introduced while learning this.

The barrier, and why the naive one only works once

A barrier holds N participants until all N have arrived, then releases them together. Built from a counter, a lock and a semaphore, the direct implementation is correct on its first use and broken on its second, because a fast participant can loop around and re-enter before the slow ones have finished leaving.

The standard fix is a two-phase turnstile: one gate to leave the previous round and a second to enter the next, so no participant can lap another. There is a cleaner variant that pre-loads the first turnstile and avoids one of the gates entirely. Neither is difficult once seen. Neither is something we would want an engineer deriving under deadline pressure at midnight while a client’s job queue is stuck, which is exactly when the problem presents itself.

barriers/reusable.pyPython
mutex      = Semaphore(1)turnstile  = Semaphore(0)   # phase 1: shut until the last party arrivesturnstile2 = Semaphore(1)   # phase 2: open, so the barrier can be re-entered with counted(mutex, +1) as n:    if n == PARTIES:        turnstile2.acquire()    # shut the exit before opening the entrance        turnstile.release()
Every initial count on those three lines is a policy decision, and the one on line 7 is the difference between a barrier and a barrier that deadlocks the second time it is used. Reading concurrent code accurately is the skill; the primitives are incidental.
cpp/multiplex.cppC++
sem_t multiplex;sem_t countsem; void count(int thread_id) {    sem_wait(&multiplex);    // ... critical section, guarded separately by countsem ...    sem_post(&multiplex);} int main() {    sem_init(&multiplex, 0, 3);   // capacity 3: the policy, stated once    sem_init(&countsem, 0, 1);    // ... spawn / join 20 threads ...    sem_destroy(&multiplex);    sem_destroy(&countsem);}
Same algorithm as the Python version and none of the same affordances: the count has to be named at initialization, every wait needs its matching post on every path out, and the teardown is yours. The third argument on line 12 is the only line in the file that decides how much concurrency this program permits.

The move that explains condition variables

In the exclusive queue pattern, where participants pair up before proceeding, the elegant implementation deliberately does not release the mutex on handoff. The partner inherits it. That looks like a violation of every rule about balanced acquisition and release, and it is exactly what a condition variable does internally when it signals.

Having written that by hand once, condition-variable code reads differently ever afterward. This is the general return on the exercise: not that you will use these primitives, but that the higher-level tools stop being magic. A channel, a mutex with a condition variable, an async runtime’s scheduler, all of them are a count, a wait and a signal underneath.

What we actually use in production

Almost never a raw semaphore. A channel, a queue with a bounded capacity, a mutex paired with a condition variable, or a task runtime is clearer at the call site and easier to review, and clarity at the call site is worth a great deal more than cleverness.

But production concurrency bugs do not present as concurrency bugs. They present as a job that ran twice, a report with a number nobody can reproduce, a queue that stalls under load once a week, or an integration that duplicates a record when the network is slow. Diagnosing those means reading code written by someone else, under a deadline, and deciding whether a specific interleaving is possible. That skill is built by writing the small versions, and it does not transfer from reading about them.

Sources

  1. 1.The Little Book of Semaphores, Allen B. Downey, Green Tea Press

Next step

Tell us what’s breaking.

Forty-five minutes, no charge, no deck. We’ll tell you what we’d do, what it would likely cost, and whether you should be building this at all.