Knowledge Guide
HomeHands-On BuildsConcurrency Katas

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:

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.

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

ApproachCorrectness surfaceSimplicityTimeout supportUse when
Hand-rolled (ReentrantLock+Condition / sync.Mutex+sync.Cond, this lab)You own the invariant — the exact bug in Movement 5 is possibleModerate; you own the wait/signal protocolJava: 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.SemaphoreSame over-release footgun as our hand-rolled version — it's documented, not fixedHighest in Java — battle-tested, handles fairness and interruption for youNative (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 liesHighest in Go — the runtime owns the wait/notify, you cannot get the loop wrong because there is no loopNative via select + time.After / context.ContextDefault 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, everHighestNative (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.

8. You can now defend


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes