Knowledge Guide
HomeConcurrencyConcurrency Foundations

3. Semaphore

A semaphore is an integer permit counter guarded by atomic operations: acquire() blocks while the count is 0 and otherwise decrements it; release() increments the count and wakes one blocked waiter — so at most N threads can be "inside" at once, where N is the count you start it with.

That single counter gives you something a mutex cannot: a mutex admits exactly one thread; a semaphore admits up to N. That makes it the right tool for bounded resource pools — N database connections, N file handles, N concurrent download slots — where the limit is a real scarcity, not just mutual exclusion.

The mechanism, concretely

Think of a semaphore as a tray holding N tokens. To enter, you must take a token; to leave, you put one back. When the tray is empty, arrivers stand in a FIFO-ish wait queue. The two operations are the whole story:

Two consequences engineers lean on: (1) the count can be more than 1, which is why it generalizes the mutex; (2) release() does not have to be called by the same thread that called acquire() — a producer can hand a permit to a consumer. That asymmetry is also what makes semaphores easy to leak (more in Pitfalls).

diagram
diagram

Why the original example was wrong

The page this replaces used a Semaphore(5) around an AtomicInteger counter. That conflates two ideas and protects nothing:

The lesson: a semaphore limits concurrency; it does not make a compound action atomic. If you want "at most N in flight" and a correct stop condition, use the semaphore for the bound and a CAS loop (or a lock) for the check-then-act.

A real example: a bounded connection pool

Here is the scenario where a semaphore genuinely earns its place. A service can hold at most 3 open database connections, but 5 request handlers want one. The semaphore is the resource accounting: a permit means "a connection is available." No connection is opened beyond 3, and waiters block instead of failing.

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class ConnectionPool {
    private final Semaphore slots = new Semaphore(3, /*fair=*/true);
    private final Connection[] pool = new Connection[3];
    private final boolean[] free  = { true, true, true };

    /** Blocks (up to timeout) until a connection is free; null if none in time. */
    public Connection borrow(long ms) throws InterruptedException {
        if (!slots.tryAcquire(ms, TimeUnit.MILLISECONDS)) return null; // no permit → caller backs off
        try {
            synchronized (this) {             // permit in hand; now safely pick a slot
                for (int i = 0; i < pool.length; i++) {
                    if (free[i]) { free[i] = false;
                        if (pool[i] == null) pool[i] = Connection.open();
                        return pool[i];
                    }
                }
            }
            // invariant broken: we held a permit but every slot was busy
            slots.release();
            throw new IllegalStateException("permit held but no free slot");
        } catch (Exception e) {
            slots.release();                  // permit was acquired but we cannot hand out a connection
            throw e;
        }
    }

    public void giveBack(Connection c) {
        synchronized (this) {
            for (int i = 0; i < pool.length; i++)
                if (pool[i] == c) { free[i] = true; break; }
        }
        slots.release();                       // hand the permit to the next waiter
    }
}

Note the division of labour: the semaphore bounds how many threads can be inside at all (never more than 3), and a small mutex (synchronized) makes the slot-selection check-then-act atomic. Two tools, two distinct jobs — exactly what the broken counter example failed to separate. The number of permits held + permits available is always 3; that is the pool's invariant.

Traced run

Five handlers (H1…H5) call borrow() against Semaphore(3, fair). Watch the permit counter and the wait queue:

StepActionpermitsWait queue (FIFO)Inside the pool
0start3
1H1 acquire → ok2H1
2H2 acquire → ok1H1, H2
3H3 acquire → ok0H1, H2, H3
4H4 acquire → blocks0H4H1, H2, H3
5H5 acquire → blocks0H4, H5H1, H2, H3
6H2 giveBack → release0→1→0H5H1, H3, H4
7H1 giveBack → release0→1→0H3, H4, H5

At steps 6 and 7 the count momentarily ticks up to 1, then the woken waiter immediately consumes it back to 0 — the count never exceeds 3, so no fourth connection is ever opened. Because the semaphore is fair, H4 is served before H5 (FIFO). With the default unfair semaphore, a thread calling acquire() at the exact moment of a release() can barge ahead of long-parked waiters — faster throughput, but H5 could starve.

Go: the same bound with a buffered channel

Go has golang.org/x/sync/semaphore, but the idiomatic counting semaphore is a buffered channel: its capacity is the permit count, a send is acquire, a receive is release.

package main

import "sync"

func main() {
    sem := make(chan struct{}, 3) // capacity 3 == 3 permits
    var wg sync.WaitGroup

    for h := 1; h <= 5; h++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            sem <- struct{}{}        // acquire: blocks when buffer is full (3 in flight)
            defer func() { <-sem }() // release: frees a slot for a waiter
            useConnection(id)        // at most 3 goroutines run this at once
        }(h)
    }
    wg.Wait()
}

Where the runtimes differ:

Pitfalls

Takeaways


Sources: Java SE API docs for java.util.concurrent.Semaphore and AbstractQueuedSynchronizer; Brian Goetz et al., Java Concurrency in Practice (Ch. 5, bounded resource pools and the Semaphore-backed BoundedHashSet); the Go memory model and the golang.org/x/sync/semaphore package docs, plus the standard buffered-channel-as-semaphore idiom from Effective Go; Dijkstra's original P/V semaphore formulation. Re-authored and deepened for this guide — replaced a conceptually muddled AtomicInteger+Semaphore code dump with a correct bounded connection-pool example, fixed the check-then-increment overshoot bug, and added the permit-counter mechanism, a traced run, a diagram, and a Go counterpart.

🤖 Don't fully get this? Learn it with Claude

Stuck on 3. Semaphore? 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 **3. Semaphore** (Concurrency) and want to truly understand it. Explain 3. Semaphore 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 **3. Semaphore** 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 **3. Semaphore** 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 **3. Semaphore** 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