Synchronization Constructs
Every synchronization construct on this page is the same machine wearing different clothes: an atomic counter (or flag) guarding shared state, plus a wait-queue of threads parked by the OS — a thread that cannot proceed atomically checks the counter, fails, and is descheduled onto the queue until another thread changes the counter and wakes it. Mutex, read/write lock, semaphore, condition variable, and barrier differ only in what the counter counts and what wakes the waiters. Learn that one mechanism and the five constructs stop being five things to memorize.
This page is the map for the lessons that follow (Mutex, Read/Write Locks, Semaphore, Condition Variables, Barriers). Below, each construct is reduced to its counter + wake rule, then one — the counting semaphore — is traced end to end with real numbers so the machinery is concrete before you meet it again in depth.
| Construct | What the counter holds | Block when | Who wakes a waiter |
|---|---|---|---|
| Mutex | owner = 1 thread or none | already owned by another | the owner unlocks |
| Read/Write lock | reader count (≥0) + 1 writer flag | want write & readers>0, or want read & writer held | last reader leaves / writer releases |
| Semaphore | integer permits N | permits == 0 | any thread that releases a permit |
| Condition variable | (no count) a predicate + a wait set | predicate false | a thread that makes the predicate true and signals |
| Barrier | arrived count, target = parties | arrived < parties | the last party to arrive (trips the barrier) |
Note the family resemblance: a mutex is a binary semaphore (permits ∈ {0,1}) with an ownership rule, and a read/write lock is two coupled counters. The condition variable is the odd one out — it owns no resource count; it is pure "park me until someone says the world changed," and is the primitive the others are often built from.
Traced example — a counting semaphore as a connection pool
Concrete setup: a database connection pool with 3 connections, so a semaphore initialized to permits = 3. Five worker threads (W1..W5) each want one connection, hold it briefly, then return it. acquire() decrements permits if >0 else parks the caller; release() increments permits and wakes one parked thread. Here is the exact interleaving with real values — watch the counter and the wait-queue, the two pieces of state that are the semaphore.
| Step | Event | permits | Wait-queue (parked) | Holding a connection |
|---|---|---|---|---|
| 0 | initial | 3 | [] | — |
| 1 | W1 acquire | 2 | [] | W1 |
| 2 | W2 acquire | 1 | [] | W1 W2 |
| 3 | W3 acquire | 0 | [] | W1 W2 W3 |
| 4 | W4 acquire → permits==0, park | 0 | [W4] | W1 W2 W3 |
| 5 | W5 acquire → permits==0, park | 0 | [W4, W5] | W1 W2 W3 |
| 6 | W2 release → wake head | 0→1→0 | [W5] | W1 W3 W4 |
| 7 | W1 release → wake head | 0→1→0 | [] | W3 W4 W5 |
| 8 | W3 release | 1 | [] | W4 W5 |
| 9 | W4, W5 release | 3 | [] | — |
The subtle steps are 6 and 7. release() raises permits to 1, but instead of letting permits stay at 1, it immediately hands that permit to the woken waiter (W4, then W5) — so permits is back to 0 and the woken thread proceeds without re-competing. Whether the hand-off is direct (fair) or whether the woken thread must re-race a fresh acquire() (barging, faster but unfair) is the single most important fairness knob, and it is exactly where Java and Go differ — see the code.
The core logic in Java and Go
The same connection-pool semaphore, side by side. Java exposes a real Semaphore class backed by AbstractQueuedSynchronizer (the atomic-counter-plus-park engine described above). Go has no semaphore type in the language; the idiomatic equivalent is a buffered channel of capacity 3 — sending a value is acquire, receiving is release, and the channel's internal buffer is the permit counter.
Java — java.util.concurrent.Semaphore
import java.util.concurrent.Semaphore;
Semaphore pool = new Semaphore(3, /* fair = */ true);
void useConnection() throws InterruptedException {
pool.acquire(); // blocks & parks if permits == 0
try {
// ... use one of the 3 connections ...
} finally {
pool.release(); // ALWAYS release, even on exception
}
}Go — buffered channel as a counting semaphore
// capacity 3 == 3 permits; the buffer is the counter
sem := make(chan struct{}, 3)
func useConnection() {
sem <- struct{}{} // acquire: blocks if buffer is full
defer func() { <-sem }() // release: frees one slot
// ... use one of the 3 connections ...
}Where the runtimes differ. A blocked Java thread parks an OS thread (a 1:1 mapping; ~1 MB stack, a kernel context-switch to wake). A blocked goroutine parks only the goroutine — the Go scheduler (M:N) hands the underlying OS thread to another runnable goroutine, so blocking is cheap (~KB stacks, no kernel switch) and you can have hundreds of thousands of waiters. Java's wake comes from LockSupport.unpark; Go's comes from the channel send making a receiver runnable. And Java Semaphore lets you choose fair (FIFO hand-off) vs. unfair (barging); a Go channel's send/receive ordering is FIFO among waiters but you do not get a non-permit-based fairness toggle.
Why the naive "check-then-act" version is wrong
A tempting hand-rolled semaphore looks like if (permits > 0) permits--;. This is a race: two threads can both read permits == 1, both pass the check, and both decrement to -1 — handing out a fourth connection that does not exist. The decrement must be atomic with the check (a CAS loop or a lock-protected counter), which is exactly why you reach for the library primitive instead of an int and an if. Likewise, parking must be done under the same lock that guards the counter, or a thread can decide to park after the only releaser has already signalled — and sleep forever (a lost wakeup). The library handles both; your if does not.
Pitfalls
- Release leak. An exception between
acquire()andrelease()permanently loses a permit; after enough leaks the pool reports zero connections forever. Always release infinally(Java) /defer(Go). Note Java'sSemaphoredoes not track ownership — any thread can release, so you can also release too many times and over-grant permits. - Mutex used across a blocking wait. Holding a mutex while waiting on a condition deadlocks the world; condition variables exist precisely to atomically release the lock and park. Never block on I/O or another lock while holding one.
- Spurious / stolen wakeups. A condition-variable waiter can wake without the predicate being true (spurious wakeup), or another thread can grab the resource between the signal and the wakeup (barging). Always re-check the predicate in a
whileloop, never anif— covered in depth in the Condition Variables lesson. - Barrier with a dying party. A barrier waits for all parties; if one thread crashes before arriving, every other thread blocks forever. Use a timeout or a "broken barrier" mechanism (Java's
CyclicBarrierthrowsBrokenBarrierException). - Reader starvation / writer starvation in RW locks. A steady stream of readers can starve a waiting writer (or vice-versa depending on policy). Know your lock's fairness policy before relying on it under load.
Takeaways
- All five constructs are the same machine: an atomic counter/flag + a wait-queue of parked threads. They differ only in what the counter counts and what wakes a waiter.
- A mutex is a fair binary semaphore with ownership; a read/write lock is two coupled counters; a barrier counts arrivals up to N; a condition variable owns no count and is the "park until the world changes" primitive the others build on.
- The check-and-decrement must be atomic — a plain
if (permits>0) permits--over-grants under contention. Use the library primitive, and always release infinally/defer. - Java blocks OS threads (expensive, 1:1); Go blocks goroutines (cheap, M:N), so the same semaphore pattern scales to far more concurrent waiters in Go.
Re-authored and deepened for this guide. Sources: Doug Lea, The java.util.concurrent Synchronizer Framework (the AQS paper underlying Java's Semaphore, ReentrantLock, and CyclicBarrier); the OpenJDK java.util.concurrent API docs (Semaphore, ReentrantReadWriteLock, Condition, CyclicBarrier); the Go Programming Language Specification and the Go Blog, "Share Memory By Communicating" (channels as the idiomatic concurrency primitive); and Dijkstra's original formulation of the semaphore (the P/V operations). The atomic-counter-plus-wait-queue framing follows Herlihy & Shavit, The Art of Multiprocessor Programming.
🤖 Don't fully get this? Learn it with Claude
Stuck on Synchronization Constructs? 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 **Synchronization Constructs** (Concurrency) and want to truly understand it. Explain Synchronization Constructs 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 **Synchronization Constructs** 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 **Synchronization Constructs** 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 **Synchronization Constructs** 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.