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:
acquire()— ifpermits > 0, atomically dopermits--and proceed. Ifpermits == 0, park this thread on the wait queue (it sleeps; it does not spin).release()— atomically dopermits++. If any thread is parked, wake exactly one; that thread re-checks and grabs the token that just appeared.
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).
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
AtomicIntegeralready makesincrementAndGet()race-free on its own. The semaphore guards no shared mutation — it only throttles how many threads run the loop body at once. The semaphore and the atomic are solving unrelated problems stapled together. - Worse, it has a real bug. The check
counter.get() >= TARGETand the increment are two separate atomic operations, not one. With 5 permits, 5 threads can all read4999, all pass the check, then all increment — landing on 5004, overshooting the target. A "stop at N" guard built from check-then-act across threads is itself a classic race; the semaphore does nothing to prevent it.
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:
| Step | Action | permits | Wait queue (FIFO) | Inside the pool |
|---|---|---|---|---|
| 0 | start | 3 | — | — |
| 1 | H1 acquire → ok | 2 | — | H1 |
| 2 | H2 acquire → ok | 1 | — | H1, H2 |
| 3 | H3 acquire → ok | 0 | — | H1, H2, H3 |
| 4 | H4 acquire → blocks | 0 | H4 | H1, H2, H3 |
| 5 | H5 acquire → blocks | 0 | H4, H5 | H1, H2, H3 |
| 6 | H2 giveBack → release | 0→1→0 | H5 | H1, H3, H4 |
| 7 | H1 giveBack → release | 0→1→0 | — | H3, 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:
- What blocks. In Java,
acquire()parks an OS thread (managed by the JVM/AQS); blocking many threads costs real kernel resources. In Go, a full-channel send parks a goroutine — the Go scheduler swaps it off its OS thread cheaply, so thousands of blocked acquirers cost almost nothing. - The wait mechanism. Java's
Semaphoreis built onAbstractQueuedSynchronizerwith explicit park/unpark and an optional fairness flag. A Go channel's waiter list is FIFO by construction — there is no "unfair" mode and no barging. - Counting up. Go's channel can only release a permit you put in (send/receive symmetry by capacity). Java's
Semaphorelets yourelease(n)or even release more than you acquired, growing the permit pool — powerful, and a common source of leaks.
Pitfalls
- Leaked permits. If
acquire()succeeds but the path torelease()throws and isn't in afinally(or Go'sdefer), that permit is gone forever. After enough leaks the count hits 0 permanently and every futureacquire()hangs — a slow, silent deadlock. Always pair acquire/release withtry/finallyordefer. - Double release. Because
release()isn't tied to the acquiring thread, calling it twice (e.g., on an error path and infinally) silently inflates the permit count above the intended N — now 4 threads slip into a "pool of 3" and you over-subscribe the real resource. Java won't stop you; the bound is only as correct as your release discipline. - Mistaking a semaphore for mutual exclusion.
Semaphore(1)acts like a lock but is not reentrant — the same thread callingacquire()twice deadlocks itself, whereas aReentrantLockwould not. Use a real lock when you need mutual exclusion. - Throttling ≠ atomicity. The original bug: limiting concurrency to N does not serialize a check-then-act. Guard compound state with a lock or CAS in addition to the semaphore.
- Unfair starvation. The default Java semaphore is unfair for throughput. Under sustained contention a parked thread can starve as fresh callers barge. Pass
fair=truewhen latency fairness matters more than peak throughput.
Takeaways
- A semaphore is a counter of permits:
acquiredecrements (or blocks at 0),releaseincrements and wakes one waiter. Count = N admits up to N threads. - Reach for it when the limit is a real bounded resource (connections, slots, rate); reach for a mutex when you need one-at-a-time exclusion.
- It bounds concurrency but does not make compound operations atomic — pair it with a lock/CAS for check-then-act.
- Always release in
finally/defer; leaks deadlock silently and double-releases break the bound. In Go, a buffered channel is a counting semaphore; goroutine parking makes blocking far cheaper than Java's OS-thread parking.
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.
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.
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.
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.
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.