Build a Semaphore from Scratch
Build a Semaphore from Scratch
Every connection pool, worker fleet, and rate-limited API client you'll ever build needs to answer one question: "at most how many of you can be doing this at once?" A mutex only ever answers "exactly one" — it has no vocabulary for "up to N." That's what a counting semaphore is for, and it's a recurring "build it live" question at Meta/Google/Amazon/Stripe-tier interviews precisely because it separates candidates who've memorized "Semaphore = mutex with a number" from candidates who understand that a semaphore has no owner, can be signaled by a thread that never acquired it, and that this single property is both its superpower and its sharpest footgun. This lab has you build a counting semaphore from an empty file, in Java and Go, break it on purpose, and use it to build a real bounded connection pool. See also the Semaphore concept page for the theory side — do this lab first and that page will read like a victory lap.
1. The trap
You're building a client for a downstream service that can only handle 10 concurrent connections before it starts timing out. Requests come from many threads. The "obvious" first draft, option A:
// Option A -- one mutex around the whole call
synchronized (lock) {
Connection c = openConnection();
Response r = c.send(request);
c.close();
}
This compiles, it's "thread-safe" in the sense that nothing corrupts — and it's also completely wrong for the job. A mutex allows exactly one thread through at a time. You have capacity for 10 concurrent connections and you're using 1. Every other thread queues up single-file behind the lock, and your expensive downstream service sits 90% idle while your own callers pile up. You didn't need mutual exclusion; you needed a bound.
Option B, the other obvious draft — skip the lock entirely and let everyone dial out:
// Option B -- no coordination at all
Connection c = openConnection();
Response r = c.send(request);
c.close();
Now 200 threads open 200 connections against a service that dies at 11. You've traded "too little concurrency" for "unbounded concurrency," which is the exact same class of bug as an unbounded queue in a producer/consumer pipeline — no back-pressure, and the failure shows up under load, at the worst possible time.
What you actually need is a primitive that tracks a count of how many callers may proceed concurrently — not zero-or-one like a mutex, not infinite like no coordination at all, but a configurable N. That primitive is a semaphore, and building one is the rest of this lab.
2. Scope it like a senior
Before writing a line, pin the contract down. Questions worth asking out loud in an interview:
- How many permits, and can that number change? Fixed at construction for this lab (a pool of N connections) — dynamic resizing is a real extension but out of scope for the core mechanism.
- Block, or give up? A caller that can't get a permit — does it wait forever, or get a bounded wait with a timeout? We build blocking
acquire()first (M1–M2), then atryAcquire(timeout)variant (M3) because "what if I don't want to block forever?" is the guaranteed follow-up. - Who is allowed to release a permit? This is the question that actually distinguishes a semaphore from a mutex, and the answer is: anyone, whether or not they ever acquired one. That's not a bug to prevent — it's the defining feature that makes semaphores useful for signaling between threads, not just mutual exclusion. We prove this explicitly in Movement 6.
- Fair, or best-effort? Does the longest-waiting thread get the next permit (FIFO), or is any woken waiter fine? Java's
Semaphoreand our hand-rolledReentrantLockboth support afairconstructor flag; Go'ssync.Condgives you no fairness guarantee at all — worth naming as a real gap, not silently assuming it away. - What's it FOR? A binary semaphore (N=1) used as a mutex substitute, versus a counting semaphore bounding a resource pool, versus a semaphore starting at zero used purely as a signal/rendezvous primitive (thread A does work, then
release()s to wake thread B, which was never "holding" anything) — these are three different mental models built on the exact same mechanism.
Answering these up front is what "senior" looks like: you're negotiating the spec, not guessing at it.
3. Reason to the design
Attempt 0 — a boolean-per-slot busy-wait. Track N "slots," have each caller spin-scan for a free one:
boolean[] slots = new boolean[10];
// caller:
while (true) {
synchronized (lock) {
for (int i = 0; i < slots.length; i++) {
if (!slots[i]) { slots[i] = true; grabbed = i; break outer; }
}
}
// didn't find one -- spin and try again
}
This has exactly the busy-wait disease from the bounded-buffer lab: when all 10 slots are taken, every waiting thread burns a core re-scanning an array that hasn't changed, instead of sleeping until it has. You don't need a boolean per slot at all — you only ever care about how many are free, not which ones. Collapse the array to a single integer: permits.
Attempt 1 — a lock and an integer, still busy-waiting. Guard the integer with a mutex so decrementing it is atomic, but still spin when it's zero:
synchronized(lock) {
while (permits == 0) { /* spin, holding nothing, wasting a core */ }
permits--;
}
Same failure as before, just tidier bookkeeping. The missing ingredient is identical to the producer/consumer lab: you want the waiting thread to genuinely sleep and be woken exactly when permits becomes nonzero — that's a job for a condition variable, not a spin loop.
Attempt 2 — one lock, one condition, the correct shape. A single Condition (Java) / sync.Cond (Go) that every waiter blocks on, paired with the permit counter:
lock.lock();
try {
while (permits == 0) permitAvailable.await(); // sleep, don't spin
permits--;
} finally { lock.unlock(); }
This is the whole mechanism. Unlike the bounded buffer (which needed two conditions — notFull and notEmpty — because it has two distinct kinds of waiter), a semaphore only ever has one kind of event worth waking someone for: "a permit became available." One condition is enough, and signal() (wake exactly one waiter) is not just safe here but the right choice — each release() makes exactly one more grant possible, so waking exactly one waiter is precise, not wasteful. Compare that to the producer/consumer lab, which needed signalAll() because multiple different types of waiter shared one condition.
The one rule that makes or breaks this, exactly as in the bounded-buffer lab: after waking from await()/Wait(), re-check the condition in a loop (while/for), never trust a wake-up unconditionally (if). Spurious wakeups are the textbook reason; a genuine race between two waiters both woken near-simultaneously is the reason you'll actually hit in practice.
The design decision that actually defines a semaphore (not shared with a mutex at all): release() takes no argument identifying "who." It doesn't check that the calling thread previously called acquire(). It just increments the counter and signals. That's not an oversight — a mutex is fundamentally an ownership token (only the thread that locked it may unlock it), while a semaphore is fundamentally a resource counter with no memory of who touched it. That absence of ownership is what lets one thread produce permits for another thread to consume — the basis of every producer-signals-consumer pattern — and it's also exactly what Movement 5 breaks.
4. Build it — milestones
Build a counting semaphore with this contract, growing it milestone by milestone. Attempt each yourself before reading the reference implementation — the reveal is positioned after the tests on purpose.
- Core contract:
acquire()blocks whilepermits == 0, then takes one;release()always succeeds and adds one back, regardless of who calls it. Capacity fixed at construction. - M1 — binary semaphore as a mutex substitute. Construct with 1 permit. Prove it behaves like mutual exclusion for the "acquire, do work, release" happy path — then prove, deliberately, that it is not a mutex by having a different thread call
release()(Movement 6). - M2 — counting semaphore, N permits, multiple waiters. Many threads acquiring/releasing against the same pool of N; this is where the
while/ifand single-condition-suffices decisions from Movement 3 get exercised for real. - M3 — bounded wait.
tryAcquire(timeout) → boolso a caller can give up instead of blocking forever (Java:awaitNanos; Go: no built-in deadline onsync.Cond, so we hand off through a channel — a real, honest cost of choosingsync.Cond, surfaced in Movement 6's trade-off table). - M4 — apply it: a bounded connection pool. Layer a real resource (a list of pooled connections) on top of the semaphore:
borrow()=acquire()+ pop a connection;giveBack()= push the connection back +release(). The semaphore governs how many may be checked out; the free-list governs which ones. Movement 5's bug lives exactly in the seam between those two.
Reference implementation — Java (ReentrantLock + one Condition, hand-rolled)
Deliberately hand-rolled, not a wrapper around java.util.concurrent.Semaphore — but built to have the identical contract, including the sharp edge. java.util.concurrent.Semaphore's own Javadoc says it plainly: "There is no requirement that a thread that releases a permit must have acquired that permit by calling acquire." That's not a quirk of our toy version — it's true of the JDK's real one too, and Movement 5 shows exactly what it costs you.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* A hand-rolled counting semaphore: one lock, one condition variable, an int permit count.
* Deliberately mirrors the real contract of java.util.concurrent.Semaphore, INCLUDING its
* sharpest edge: release() is ownerless -- any thread may call it, whether or not it ever
* called acquire() -- and there is no upper bound check on the permit count.
*/
public final class CountingSemaphore {
private final ReentrantLock lock;
private final Condition permitAvailable;
private int permits;
public CountingSemaphore(int initialPermits) {
this(initialPermits, false);
}
public CountingSemaphore(int initialPermits, boolean fair) {
if (initialPermits < 0) throw new IllegalArgumentException("initialPermits must be >= 0");
this.lock = new ReentrantLock(fair);
this.permitAvailable = lock.newCondition();
this.permits = initialPermits;
}
/** Blocks until a permit is available, then takes it. */
public void acquire() throws InterruptedException {
lock.lock();
try {
while (permits == 0) { // while, not if -- same lesson as the P/C lab
permitAvailable.await();
}
permits--;
} finally {
lock.unlock();
}
}
/** Bounded wait variant: gives up instead of blocking forever. */
public boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException {
long nanos = unit.toNanos(timeout);
lock.lock();
try {
while (permits == 0) {
if (nanos <= 0L) return false;
nanos = permitAvailable.awaitNanos(nanos);
}
permits--;
return true;
} finally {
lock.unlock();
}
}
/**
* Ownerless by design: ANY thread may call release(), whether or not it ever called
* acquire(). No cap is enforced here -- exactly like java.util.concurrent.Semaphore,
* whose Javadoc states: "There is no requirement that a thread that releases a permit
* must have acquired that permit by calling acquire." That is a deliberate feature for
* signaling patterns, and the exact footgun Movement 5 breaks on purpose.
*/
public void release() {
lock.lock();
try {
permits++; // no upper-bound check -- see Movement 5
permitAvailable.signal(); // exactly one more grant is available -> wake exactly one waiter
} finally {
lock.unlock();
}
}
public int availablePermits() {
lock.lock();
try {
return permits;
} finally {
lock.unlock();
}
}
}
Movement 4's payoff — a bounded connection pool built on top of it:
import java.util.ArrayDeque;
import java.util.Deque;
/** A bounded resource pool built ON TOP OF the semaphore -- Movement 4's payoff. */
public final class ConnectionPool {
/** A stand-in for a real DB connection; tracks concurrent holders to detect corruption. */
static final class Connection {
final int id;
volatile int activeUsers = 0;
Connection(int id) { this.id = id; }
}
private final CountingSemaphore permits;
private final Deque<Connection> free = new ArrayDeque<>();
ConnectionPool(int capacity) {
this.permits = new CountingSemaphore(capacity);
for (int i = 0; i < capacity; i++) free.push(new Connection(i));
}
/** Borrow: take a permit, then pop a physical connection. Semaphore count and free-list size
* are only in sync if release() is called exactly once per successful borrow(). */
Connection borrow() throws InterruptedException {
permits.acquire();
synchronized (free) {
return free.pop(); // safe ONLY because the semaphore guarantees free is non-empty here
}
}
/** Correct give-back: one release() per one borrow(). */
void giveBack(Connection c) {
synchronized (free) {
free.push(c);
}
permits.release();
}
int availablePermits() { return permits.availablePermits(); }
int freeListSize() { synchronized (free) { return free.size(); } }
/** THE BUG (Movement 5): calls release() directly, bypassing giveBack(), so the permit
* count grows without a matching connection being pushed back onto the free list. */
void rawSemaphoreReleaseBUG() {
permits.release();
}
}
Both classes compiled clean under javac -Xlint:all (zero warnings) and were executed before writing this page.
Reference implementation — Go (sync.Mutex + sync.Cond, hand-rolled) AND the idiomatic buffered-channel semaphore
Go gives you two genuinely different ways to build this. First, the manual idiom mirroring the Java design line for line — notice the for loop, which is Go's spelling of Java's while:
package semaphore
import (
"sync"
"time"
)
// CountingSemaphore is a hand-rolled counting semaphore: one Mutex, one
// sync.Cond, an int permit count. It deliberately mirrors the same
// ownerless-release contract as the Java version: any goroutine may call
// Release(), whether or not it ever called Acquire(), and there is no
// upper-bound check on the permit count.
type CountingSemaphore struct {
mu sync.Mutex
permitAvailable *sync.Cond
permits int
}
func NewCountingSemaphore(initialPermits int) *CountingSemaphore {
if initialPermits < 0 {
panic("initialPermits must be >= 0")
}
s := &CountingSemaphore{permits: initialPermits}
s.permitAvailable = sync.NewCond(&s.mu)
return s
}
// Acquire blocks until a permit is available, then takes it.
func (s *CountingSemaphore) Acquire() {
s.mu.Lock()
defer s.mu.Unlock()
for s.permits == 0 { // for, not if -- same lesson as the P/C lab
s.permitAvailable.Wait()
}
s.permits--
}
// TryAcquire is the bounded-wait variant: gives up after timeout instead of
// blocking forever. sync.Cond has no built-in deadline, so we poll a
// waiter goroutine via a channel handoff -- the honest cost of choosing
// sync.Cond over channels (see Movement 6's trade-off table).
func (s *CountingSemaphore) TryAcquire(timeout time.Duration) bool {
done := make(chan bool, 1)
go func() {
s.mu.Lock()
defer s.mu.Unlock()
for s.permits == 0 {
s.permitAvailable.Wait()
}
s.permits--
done <- true
}()
select {
case ok := <-done:
return ok
case <-time.After(timeout):
return false
}
}
// Release is ownerless by design: ANY goroutine may call it, whether or not
// it ever called Acquire(). No cap is enforced -- matches java.util.concurrent.Semaphore's
// documented behavior, and is the exact footgun Movement 5 breaks on purpose.
func (s *CountingSemaphore) Release() {
s.mu.Lock()
defer s.mu.Unlock()
s.permits++ // no upper-bound check -- see Movement 5
s.permitAvailable.Signal() // exactly one more grant is available -> wake exactly one waiter
}
func (s *CountingSemaphore) AvailablePermits() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.permits
}
// ChanSemaphore is the idiomatic Go encoding: a buffered channel IS the
// semaphore. Acquire = send a token (blocks once `capacity` tokens are in
// flight); Release = receive a token (frees a slot). Note what this buys
// you for free (Movement 6): you cannot silently over-release. An extra
// Release() call with no matching Acquire() just blocks forever trying to
// receive from an empty channel -- a hang you will notice, not a corrupted
// counter you won't.
type ChanSemaphore struct {
tokens chan struct{}
}
func NewChanSemaphore(capacity int) *ChanSemaphore {
return &ChanSemaphore{tokens: make(chan struct{}, capacity)}
}
func (c *ChanSemaphore) Acquire() {
c.tokens <- struct{}{}
}
func (c *ChanSemaphore) TryAcquire(timeout time.Duration) bool {
select {
case c.tokens <- struct{}{}:
return true
case <-time.After(timeout):
return false
}
}
func (c *ChanSemaphore) Release() {
<-c.tokens
}
func (c *ChanSemaphore) AvailablePermits() int {
return cap(c.tokens) - len(c.tokens)
}
And the connection pool built on the hand-rolled version, mirroring the Java M4:
package semaphore
import "sync"
// Connection is a stand-in for a real DB connection.
type Connection struct {
ID int
}
// ConnectionPool is a bounded resource pool built ON TOP OF the semaphore --
// Movement 4's payoff.
type ConnectionPool struct {
permits *CountingSemaphore
mu sync.Mutex
free []*Connection
}
func NewConnectionPool(capacity int) *ConnectionPool {
p := &ConnectionPool{permits: NewCountingSemaphore(capacity)}
for i := 0; i < capacity; i++ {
p.free = append(p.free, &Connection{ID: i})
}
return p
}
// Borrow takes a permit, then pops a physical connection. Safe ONLY because
// the semaphore guarantees `free` is non-empty at this point -- an invariant
// that a stray extra Release() breaks (Movement 5).
func (p *ConnectionPool) Borrow() *Connection {
p.permits.Acquire()
p.mu.Lock()
defer p.mu.Unlock()
n := len(p.free)
c := p.free[n-1] // will panic (index out of range) if free is empty -- the bug's symptom
p.free = p.free[:n-1]
return c
}
// GiveBack is the correct path: one Release() per one Borrow().
func (p *ConnectionPool) GiveBack(c *Connection) {
p.mu.Lock()
p.free = append(p.free, c)
p.mu.Unlock()
p.permits.Release()
}
func (p *ConnectionPool) AvailablePermits() int { return p.permits.AvailablePermits() }
func (p *ConnectionPool) FreeListSize() int {
p.mu.Lock()
defer p.mu.Unlock()
return len(p.free)
}
// RawSemaphoreReleaseBUG is THE BUG (Movement 5): calls Release() directly,
// bypassing GiveBack(), so the permit count grows without a matching
// connection being pushed back onto the free list.
func (p *ConnectionPool) RawSemaphoreReleaseBUG() {
p.permits.Release()
}
Both files pass gofmt -l (clean), go vet ./... (clean), go build ./... (clean), and a go test -race ./... suite that hammers the semaphore with 20 concurrent goroutines against 3 permits and asserts the max concurrently-inside count never exceeds capacity — all green, no races, before writing this page.
5. Break it — the test that fails
The bug isn't a typo like if-instead-of-while this time (that lesson was the bounded-buffer lab's). This bug is using the ownerless-release feature correctly according to the semaphore, but incorrectly according to the pool built on top of it — which is exactly the kind of bug a semaphore's design invites if you're not careful, because nothing about the semaphore itself can detect it.
Scenario: a connection pool with capacity 2 (exactly 2 physical Connection objects). A cleanup path — say, a catch block that "defensively" releases a connection that a finally block also released — calls the raw semaphore's release() one extra time, without pushing a connection back onto the free list. The semaphore has no way to know this release doesn't correspond to a real, physical connection — that's the entire point of ownerless release. Its permit count just goes up by one, silently:
ConnectionPool pool = new ConnectionPool(2); // exactly 2 physical connections
Connection c1 = pool.borrow(); // permits 2->1
Connection c2 = pool.borrow(); // permits 1->0
pool.giveBack(c1); // CORRECT: permits 0->1, free=[c1]
pool.rawSemaphoreReleaseBUG(); // THE BUG: an extra release, no connection pushed back
// permits is now 2, but the free list has only 1 real connection in it.
Connection c3 = pool.borrow(); // succeeds: pops the real c1, permits 2->1
Connection c4 = pool.borrow(); // permits says 1 is available... but free list is EMPTY
Run it (this is the actual output, captured verbatim from java BreakItDemo):
after both borrows: permits=0 freeListSize=0
after correct giveBack(c1): permits=1 freeListSize=1
after buggy EXTRA release(): permits=2 freeListSize=1 <-- permits no longer matches physical connections available
borrow() #3 succeeded, got connection id=1
borrow() #4 (permits says 1 is available, but the free list is truly empty):
BUG REPRODUCED: NoSuchElementException -- the semaphore said a permit was available, but no physical
connection existed to hand out. The permit count and the resource count silently desynced the moment
the extra release() ran.
Correct version (no extra release):
permits=1 freeListSize=1 (in sync: exactly one release per one borrow)
borrow() #3 succeeded, got connection id=1 -- no desync, no exception.
This is not a rare interleaving — it's a deterministic, single-threaded logic bug that reproduces on every run, no -race flag or timing luck required. One stray release() call is all it takes to make the semaphore's bookkeeping lie about how many real resources exist. In a system where the "resource" isn't a connection that throws a clean exception on underflow but, say, a fixed-size in-memory buffer, this same bug wouldn't throw at all — it would silently corrupt memory or hand two unrelated callers the same object. The Java exception here is a mercy; plenty of real systems don't get one.
The Go hand-rolled version reproduces the identical desync, deterministically, under go run:
== Movement 5: break it (over-release desyncs the pool) ==
after both borrows: permits=0 freeListSize=0
after correct GiveBack(c1): permits=1 freeListSize=1
after buggy EXTRA Release(): permits=2 freeListSize=1 <-- desynced
Borrow() #3 succeeded, got connection id=1
Borrow() #4 (permits says 1 is available, but the free list is truly empty):
BUG REPRODUCED: panic(runtime error: index out of range [-1]) -- the semaphore said a permit was
available, but no physical connection existed to hand out. The permit count and the resource count
silently desynced the moment the extra Release() ran.
Correct version (no extra release):
permits=1 freeListSize=1 (in sync: exactly one release per one borrow)
Borrow() #3 succeeded, got connection id=1 -- no desync, no panic.
The fix is disciplinary, not mechanical: never call the raw semaphore's release() directly from application code that's managing a resource pool — route every release through one function (giveBack) that atomically pairs "push the resource back" with "increment the permit," so the two can never drift apart. The semaphore's ownerless-release feature is exactly right for signaling between unrelated threads; it is exactly wrong to expose as a public API on a resource pool, where "how many permits" and "which resources are actually free" must never be allowed to disagree.
6. Optimise — with trade-offs
| Approach | Correctness surface | Simplicity | Timeout support | Use when |
|---|---|---|---|---|
Hand-rolled (ReentrantLock+Condition / sync.Mutex+sync.Cond, this lab) | You own the invariant — the exact bug in Movement 5 is possible | Moderate; you own the wait/signal protocol | Java: native (awaitNanos). Go: must hand-roll via a channel handoff (see M3) | Learning the mechanism; or when you need a custom wake condition (e.g. wake only when ≥5 permits are free) that neither stdlib option expresses directly |
java.util.concurrent.Semaphore | Same over-release footgun as our hand-rolled version — it's documented, not fixed | Highest in Java — battle-tested, handles fairness and interruption for you | Native (tryAcquire(timeout, unit)) | Default choice for any real Java system needing a counting semaphore; don't hand-roll in production, hand-roll to learn |
| Go buffered-channel semaphore (this lab) | Structurally safer: cannot silently over-release — an extra Release() (receive) with nothing to receive just blocks forever, a hang you'll notice, not a counter that quietly lies | Highest in Go — the runtime owns the wait/notify, you cannot get the loop wrong because there is no loop | Native via select + time.After / context.Context | Default choice in idiomatic Go for bounding concurrency (worker pools, fan-out limiting); prefer over hand-rolled sync.Cond unless you need a non-channel-expressible wake condition |
Plain mutex (ReentrantLock / sync.Mutex) | Simplest possible — only one holder, ever | Highest | Native (tryLock(timeout) in Java; Go's plain Mutex has no timeout at all) | You need exactly one concurrent holder, not "up to N" — and, in Java, you need the JVM to enforce owner-only unlock for you (Go's Mutex won't, see below) |
The real judgment call: in Java, never hand-roll a production semaphore — java.util.concurrent.Semaphore is correct, fast, and handles fairness/interruption edge cases you don't want to re-derive; hand-roll only to learn the mechanism, exactly as this lab does. In Go, prefer the buffered-channel idiom over sync.Cond by default — it buys you a structural guarantee (can't silently over-release) that the counter-based version can never give you, at the cost of losing custom wake conditions and native timeout support without a select wrapper. Reach for sync.Cond only when the channel encoding genuinely can't express your wait condition (e.g., "wake only when N-of-M sub-tasks report done," which isn't a simple token count).
Binary semaphore vs. mutex — the ownership difference, proven, not asserted. Same "1 permit" configuration, fundamentally different contract. Java's ReentrantLock tracks an owning thread and throws if a different thread tries to unlock it; our binary semaphore (and the real java.util.concurrent.Semaphore) has no such check — any thread may release it:
import java.util.concurrent.locks.ReentrantLock;
/**
* Movement 6's core claim, proven rather than asserted: a binary semaphore has NO owner --
* any thread may release it -- while a mutex enforces owner-only unlock and throws if you
* break that rule.
*/
public final class OwnershipDemo {
public static void main(String[] args) throws InterruptedException {
// -- Binary semaphore: thread A acquires, thread B releases. This is LEGAL. --
CountingSemaphore binarySem = new CountingSemaphore(1);
binarySem.acquire(); // main thread acquires the single permit
System.out.println("main thread acquired the binary semaphore (permits=" + binarySem.availablePermits() + ")");
Thread releaser = new Thread(() -> {
binarySem.release(); // a DIFFERENT thread releases it -- allowed, no exception
System.out.println("worker thread released it (permits=" + binarySem.availablePermits() + ") -- no owner check, no exception");
});
releaser.start();
releaser.join();
// -- ReentrantLock (a mutex): thread A locks, thread B tries to unlock. ILLEGAL. --
ReentrantLock mutex = new ReentrantLock();
mutex.lock();
System.out.println("main thread locked the mutex");
Thread illegalUnlocker = new Thread(() -> {
try {
mutex.unlock();
System.out.println("UNEXPECTED: unlock from a non-owner thread succeeded");
} catch (IllegalMonitorStateException e) {
System.out.println("EXPECTED: " + e.getClass().getSimpleName()
+ " -- a mutex enforces owner-only unlock; the semaphore above did not.");
}
});
illegalUnlocker.start();
illegalUnlocker.join();
mutex.unlock(); // the true owner unlocks cleanly
System.out.println("main thread (the true owner) unlocked cleanly");
}
}
Actual captured output from java OwnershipDemo:
main thread acquired the binary semaphore (permits=0)
worker thread released it (permits=1) -- no owner check, no exception
main thread locked the mutex
EXPECTED: IllegalMonitorStateException -- a mutex enforces owner-only unlock; the semaphore above did not.
main thread (the true owner) unlocked cleanly
A real cross-language nuance worth knowing, verified rather than assumed: Go's sync.Mutex does not enforce ownership either — any goroutine may call Unlock(), even one that never called Lock(). This is not a lesser implementation; it's a deliberate, documented Go design choice (a Mutex in Go is closer in spirit to a binary semaphore already than to Java's ReentrantLock). Verified with go run (excerpted from Movement 6's ownershipDemo()):
main goroutine acquired the binary semaphore (permits=0)
worker goroutine released it (permits=1) -- no owner check, no panic
main goroutine locked a plain sync.Mutex
worker goroutine unlocked it too -- Go's sync.Mutex has NO owner check either (this would throw
IllegalMonitorStateException in Java's ReentrantLock)
The takeaway: "a mutex enforces ownership" is a JVM/pthread-style convention, not a law of nature. What's actually true everywhere is the more precise claim — a semaphore's contract never includes an ownership check, by design, which is why it can do things a mutex-with-ownership-enforcement can't (cross-thread signaling), and why Movement 5's bug is possible in the first place.
7. Defend under drilling
An interviewer will push on the design. These are the follow-ups that come up almost every time, with the answer a staff engineer gives — short, concrete, no hedging.
- "If a semaphore has no owner, how do you prevent the over-release bug from Movement 5 in a real system?" You don't rely on the semaphore to prevent it — you can't, that's the whole point of ownerless release. You prevent it structurally: never expose the raw
acquire()/release()pair to application code that manages a paired resource; wrap both inside one function (borrow()/giveBack(), or Java's try-with-resources via aCloseablepermit) so it's structurally impossible to call one without the other exactly once. The semaphore is a low-level primitive; the pool is where the invariant actually lives. - "Why would you ever want release() to be ownerless — isn't that just a bug waiting to happen?" It's the entire reason semaphores exist as a distinct primitive from mutexes. Classic use: a semaphore initialized to 0, where a producer thread calls
release()N times to signal "N items are ready," and a consumer thread that never touchedacquire()before now blocks on it N times to consume them. A mutex can't express this at all — there's no "unlock from a thread that never locked" story for a mutex. Ownerless release is what makes semaphores the right tool for signaling and rendezvous, not just mutual exclusion. - "Fair vs. non-fair — what's the actual cost?" Non-fair (the default) lets whichever waiting thread the OS/runtime happens to wake next grab the permit — higher throughput, because it avoids the bookkeeping and context-switch overhead of strict FIFO ordering, but a thread can theoretically starve under sustained contention if it's unlucky. Fair (
new Semaphore(n, true)in Java) guarantees FIFO order — no starvation, but you pay for a queue and generally lower throughput under heavy contention. Default to non-fair; reach for fair only when you've actually observed starvation, not preemptively. - "What's the distributed analogue of a semaphore?" A distributed rate limiter or connection-pool limiter across multiple processes/machines — same "at most N concurrent" contract, but the counter now lives in Redis (
INCR/DECRwith a TTL as a safety net against a crashed holder that never decrements) or as a lease in etcd/ZooKeeper. The TTL is the distributed system's answer to "what if the releaser crashes and never calls release()" — a local semaphore has no equivalent safety net at all, which is worth naming as a real gap in the in-process version. - "What breaks at 100× the load?" The single lock guarding the permit counter becomes the bottleneck — every acquire/release serializes through it, so you plateau well before 100× regardless of thread count. The fix is exactly the channel-semaphore's answer at small scale (remove the explicit lock by using a lock-free structure) or, at very large scale, shard the semaphore itself — N/k permits across k independent semaphores, with callers hashed across shards — accepting slightly imprecise global bounding in exchange for removing the single point of serialization.
8. You can now defend
- You can implement a counting semaphore from scratch with a lock and one condition variable, in both Java and Go, and explain why one condition suffices here when the bounded-buffer lab needed two.
- You've broken a semaphore-backed resource pool with a single stray
release()call and watched it desync the permit count from the real resource count — deterministically, with no race required — so "route every release through one paired function" is now a scar, not a rule you memorized. - You can prove, not just assert, the ownership difference between a binary semaphore and a mutex in both Java and Go — including the real cross-language nuance that Go's
sync.Mutexdoesn't enforce ownership either. - You can place hand-rolled,
java.util.concurrent.Semaphore, Go channel-semaphore, and plain-mutex on a trade-off spectrum and argue the correctness/simplicity/timeout trade-off for each, with a concrete "use when" — and escalate the mechanism to its distributed analogue (Redis/etcd lease-based limiting) on demand.
Re-authored/Deepened for this guide. Reference code compiled and executed (javac -Xlint:all + java; go build, go vet, gofmt, go test -race) before publishing; the break-it test reproduces the permit/resource desync bug deterministically on every run. See also: the Semaphore concept page, Mutex Lock, and Condition Variables.
🤖 Don't fully get this? Learn it with Claude
Stuck on Build a Semaphore from Scratch? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Build a Semaphore from Scratch** (Hands-On Builds) and want to truly understand it. Explain Build a Semaphore from Scratch from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Build a Semaphore from Scratch** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Build a Semaphore from Scratch** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Build a Semaphore from Scratch** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.