4. Condition Variables
A condition variable lets a thread atomically release a lock and park itself in a wait-set, so it consumes zero CPU until another thread changes the shared state and signals it — at which point the parked thread is moved back to the lock's entry queue and only resumes once it re-acquires the lock. That single atomic "release-and-sleep" step is the whole point: it closes the race window where a thread checks a condition, finds it false, and falls asleep after the signal has already been sent.
The motivating problem is producer/consumer: a consumer must not read sharedNumber until a producer has written it. The naive fix is to spin on a flag.
The busy-wait version (correct, but wasteful)
This works, but the consumer holds a core hostage, repeatedly locking, checking ready, unlocking, and sleeping 1 ms. With thousands of waiters this burns real CPU and adds latency jitter.
public class Solution {
private static final Object mtx = new Object();
private static int sharedNumber;
private static boolean ready = false;
private static void producer() {
synchronized (mtx) {
sharedNumber = 42; // produce
ready = true;
}
}
private static void consumer() {
while (true) { // BUSY WAIT — spins
synchronized (mtx) {
if (ready) {
System.out.println("Consumed: " + sharedNumber);
break;
}
}
try { Thread.sleep(1); } // poll every 1ms
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
}The condition-variable version
In Java, every object is a condition variable: wait()/notify() are methods on the intrinsic monitor you enter with synchronized. The consumer waits inside a while (!ready) loop — never an if — for reasons traced in the Pitfalls section.
public class Solution {
private static final Object mtx = new Object();
private static int sharedNumber;
private static boolean ready = false;
public static void producer() {
synchronized (mtx) { // 1. acquire monitor
sharedNumber = 42; // 2. mutate shared state
ready = true; // 3. set predicate BEFORE signalling
mtx.notify(); // 4. wake one waiter (still holding lock)
} // 5. release monitor here
}
public static void consumer() {
synchronized (mtx) { // 1. acquire monitor
while (!ready) { // 2. re-check predicate every wake
try {
mtx.wait(); // 3. ATOMICALLY release + park; re-acquire on wake
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println("Consumed: " + sharedNumber);
} // 4. release monitor
}
}Tracing the mechanism with real values
Assume the consumer thread C starts and wins the race to run first, so it enters the monitor while ready == false and sharedNumber == 0. Producer thread P runs slightly later. Here is the exact step-by-step interleaving.
| Step | Thread | Action | Holds monitor? | ready | C's location |
|---|---|---|---|---|---|
| 1 | C | synchronized(mtx) — enters | C | false | in monitor |
| 2 | C | while(!ready) → true, calls mtx.wait() | C | false | about to park |
| 3 | C | wait() atomically releases lock + enters wait-set | none | false | wait-set (0% CPU) |
| 4 | P | synchronized(mtx) — enters (now free) | P | false | wait-set |
| 5 | P | sharedNumber = 42; ready = true | P | true | wait-set |
| 6 | P | mtx.notify() — moves C to entry queue | P | true | entry queue (blocked) |
| 7 | P | exits synchronized — releases lock | none | true | entry queue |
| 8 | C | re-acquires lock, returns from wait() | C | true | in monitor |
| 9 | C | while(!ready) → false, falls through | C | true | in monitor |
| 10 | C | prints "Consumed: 42"; exits monitor | none | true | done |
The load-bearing step is 3. Releasing the lock and sleeping are one indivisible operation, so there is no instant where C has given up the lock but is not yet listening. Step 6 shows notify() does not hand C the lock — it only relocates C from the wait-set to the entry queue. C cannot run until P releases the monitor at step 7 and C wins it back at step 8.
The same logic in Go
Go has the equivalent primitive in sync.Cond, and it maps one-for-one: L is the lock the monitor holds for you in Java, Wait() is the atomic release-and-park, Signal() is notify(). The for !ready loop is mandatory for the identical reasons.
package main
import "sync"
func main() {
mu := sync.Mutex{}
cond := sync.NewCond(&mu)
var sharedNumber int
ready := false
done := make(chan struct{})
go func() { // consumer
mu.Lock()
for !ready { // loop, not if
cond.Wait() // atomically unlocks mu, parks; re-locks on wake
}
println("Consumed:", sharedNumber)
mu.Unlock()
close(done)
}()
go func() { // producer
mu.Lock()
sharedNumber = 42
ready = true // set predicate before signalling
cond.Signal() // like notify(); still holds mu
mu.Unlock()
}()
<-done
}But idiomatic Go almost never reaches for sync.Cond. A channel is the condition variable — the handoff and the signal are the same act, and the for !ready loop disappears because a value either arrives or it doesn't:
func main() {
ch := make(chan int) // unbuffered: send blocks until receive
go func() { ch <- 42 }() // producer: blocks here until consumed
fmt.Println("Consumed:", <-ch) // consumer: blocks here until produced
}Where the runtimes differ. Java's wait() parks an OS thread; the kernel scheduler does the wakeup, so a blocked thread costs ~1 MB of stack and a context switch. Go's cond.Wait() and channel receives park a goroutine — a few KB on a growable stack — and the Go runtime scheduler re-runs it on an existing OS thread, so a program can have hundreds of thousands of waiters cheaply. Java models coordination as "guard shared state with a monitor and signal it"; Go's CSP model prefers "don't share state — pass it down a channel," which sidesteps the lost-wakeup and missed-predicate classes of bug entirely.
Pitfalls
Why the naive if (!ready) wait() is wrong
Replacing the while with an if compiles and usually passes tests, then fails in production for two reasons:
- Spurious wakeups. The JVM and OS are permitted to return from
wait()with no matchingnotify(). With anif, the thread falls through and readssharedNumberwhilereadyis still false. Thewhilere-checks the predicate and parks again. (Go'ssync.Cond.Waitcarries the identical guarantee gap — always loop.) - Multiple waiters +
notifyAll. If three consumers wait and the producer makes one item then callsnotifyAll(), all three wake and contend for the lock. With anif, all three would consume — but only one item exists. Thewhilemeans the two losers re-test, find the item gone, and re-park.
Lost wakeup
If the producer calls notify() before the consumer reaches wait(), the signal is gone forever — notify() wakes only threads already in the wait-set; it is not latched. The fix is exactly the structure above: set the predicate (ready = true) under the same lock, and have the waiter test the predicate before waiting. If ready is already true the consumer never calls wait() at all, so there is nothing to miss.
Calling wait/notify without holding the lock
Java throws IllegalMonitorStateException if you call mtx.wait() or mtx.notify() outside a synchronized(mtx) block; Go panics if you call cond.Wait() without holding cond.L. The lock is what makes the predicate check and the park atomic — skipping it reopens the lost-wakeup race.
notify() vs notifyAll()
Use notify() (one waiter) only when any single waiter can make progress and waiters are interchangeable. If waiters wait on different predicates sharing one monitor, notify() may wake the wrong one, which re-parks, and the right one starves. The safe default is notifyAll() / cond.Broadcast() — correctness over a small efficiency cost.
Takeaways
- A condition variable's value is the atomic release-lock-and-sleep step — it closes the gap between checking a condition and going to sleep, which a flag + sleep loop cannot.
- Always wait inside
while (!predicate), neverif: spurious wakeups and multi-waiternotifyAllboth wake threads when the predicate is still false. notify()does not transfer the lock — it moves a waiter from the wait-set to the entry queue; the waiter runs only after re-acquiring the lock. So set the predicate before signalling, under the same lock, to avoid lost wakeups.- Java waits park OS threads; Go's
sync.Condparks cheap goroutines, but idiomatic Go replaces the whole pattern with a channel, where send/receive is both the handoff and the signal.
Sources: Brian Goetz et al., Java Concurrency in Practice (ch. 14, "Building Custom Synchronizers," on condition predicates and the while-loop / lost-wakeup rules); the Oracle Java SE documentation for Object.wait/notify/notifyAll; the Go standard library docs for sync.Cond and the "Share memory by communicating" guidance from Effective Go. The earlier producer/consumer Java examples and the busy-wait contrast were retained. Re-authored and deepened for this guide: removed orphaned C++/Python/C# boilerplate that described code not shown, added a step-by-step interleaving trace with real values, a wait-set state diagram, a Go sync.Cond-and-channel counterpart with runtime differences, and explicit lost-wakeup / spurious-wakeup pitfalls.
🤖 Don't fully get this? Learn it with Claude
Stuck on 4. Condition Variables? 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 **4. Condition Variables** (Concurrency) and want to truly understand it. Explain 4. Condition Variables 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 **4. Condition Variables** 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 **4. Condition Variables** 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 **4. Condition Variables** 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.