Knowledge Guide
HomeConcurrencyConcurrency Foundations

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.

StepThreadActionHolds monitor?readyC's location
1Csynchronized(mtx) — entersCfalsein monitor
2Cwhile(!ready) → true, calls mtx.wait()Cfalseabout to park
3Cwait() atomically releases lock + enters wait-setnonefalsewait-set (0% CPU)
4Psynchronized(mtx) — enters (now free)Pfalsewait-set
5PsharedNumber = 42; ready = truePtruewait-set
6Pmtx.notify() — moves C to entry queuePtrueentry queue (blocked)
7Pexits synchronized — releases locknonetrueentry queue
8Cre-acquires lock, returns from wait()Ctruein monitor
9Cwhile(!ready) → false, falls throughCtruein monitor
10Cprints "Consumed: 42"; exits monitornonetruedone

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.

diagram
diagram

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:

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


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.

🎨 Explain it visually

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

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

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

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.

📝 My notes