What a monitor actually is
A monitor is a lock bundled with one or more condition variables, wrapped around the shared data both are protecting. That's the whole idea — nothing more mystical than "a mutex, a wait-queue, and some state, packaged together so you can't touch the state without holding the lock." The term goes back to Per Brinch Hansen and C.A.R. Hoare in the early-to-mid 1970s, and the "condition variable" itself was Hoare's name for what Dijkstra had earlier called a "private semaphore" — a queue a thread can put itself on when the world isn't in the state it needs, and that some other thread can wake once it changes that state.
You should recognize the monitor pattern as: acquire the lock → check whether some predicate over the shared state holds → if not, wait → do the actual work → release the lock. Every synchronized method in Java, every with lock: ... cond.wait() block in Python, and every pthread_mutex_lock + pthread_cond_wait pair in C is an instance of this same shape. The reason this deserves its own subtopic, distinct from plain locks (previous topic) and semaphores (previous-previous topic), is that a lock alone only gives you mutual exclusion — it says nothing about waiting for a condition. A semaphore's counter can be repurposed to express many conditions, but it's a blunt instrument: it doesn't let you name "the buffer is not empty" as a first-class thing to wait on, separate from "the buffer is not full." A monitor's condition variable does exactly that, and lets you have as many independent conditions as you need over the same protected state.
Hoare monitors vs. Mesa monitors — and why this matters
Hoare's original 1974 formulation had a strict rule: when a thread signals a condition variable, control is handed immediately to one waiting thread, and the signaler is suspended until that waiter either exits the monitor or waits again. This is called signal-and-wait semantics. Its appeal is that the woken thread can trust the condition it waited for is still true the instant it resumes — nothing else could have run in between — so the guard can be a simple if.
Almost no real system works this way, because it's expensive: it forces an extra context switch on every signal, and it requires the runtime to hand off the lock atomically as part of the signal operation, which is awkward to implement efficiently. Instead, Lampson and Redell's 1980 paper on the Mesa language (Xerox PARC) proposed signal-and-continue: the signaling thread keeps running and keeps the lock; the woken thread is merely moved to "ready" and has to compete for the lock like anyone else, whenever the signaler eventually releases it. This is called Mesa-style monitors, and it's what POSIX threads, Java, C++'s std::condition_variable, Python's threading.Condition, and essentially every mainstream language runtime actually implements.
The trade-off: Mesa semantics are cheaper and simpler to implement, but they break the guarantee that "woken means the condition is true." By the time a woken thread actually gets scheduled and reacquires the lock, an arbitrary amount of program logic may have run — including another thread grabbing the very resource the woken thread was waiting for. This is the real reason (more on this in the next subtopic) that guarded waits are always written as while (!condition) wait(); rather than if (!condition) wait(); in every mainstream language. If you remember one fact from this comparison, make it this: the language you're using almost certainly gives you Mesa semantics, so never trust that a condition still holds just because you were woken up for it.
The precise semantics of wait() — and why atomicity is the whole point
wait() (spelled notify/wait in Java, signal/wait in POSIX and most Condition APIs, notify/wait in Python) does two things as a single atomic step:
- Release the lock associated with the condition variable.
- Block the calling thread until it is woken.
Then, before wait() returns control to your code, it reacquires that same lock. Both halves of the atomicity matter, but the first is the one people underrate. Consider what would happen if "check the condition" and "start waiting" were two separate, non-atomic steps:
# BROKEN — check and wait are not atomic
if not condition:
release_lock()
# <-- another thread can run HERE: it changes the state and
# signals, but nobody is listening yet, so the signal is lost
block_until_woken()
If another thread manages to change the state and signal in the gap between "release the lock" and "actually start waiting," that signal has nowhere to land — it isn't queued or remembered, it simply vanishes (this is exactly why condition variables are not like counting semaphores, whose post() a future wait() will still observe). The waiting thread then blocks forever even though the condition it wanted is already true. This is the classic lost wakeup problem, and it's precisely what wait()'s atomicity is designed to prevent: because releasing the lock and beginning to wait happen as one indivisible operation while the lock is held, no other thread can slip in between "I decided to wait" and "I am now actually listening for a signal." Any thread that wants to change the state and signal must first acquire the same lock — so it's serialized behind the waiter's atomic release-and-block, not racing it.
This is also why wait() always requires you to already be holding the lock when you call it, and why it hands the lock back before returning: the entire contract only works if checking the predicate, waiting, and re-checking the predicate all happen with that same lock providing mutual exclusion around the shared state the whole time.
notify/signal vs. notifyAll/broadcast
Every condition variable API gives you two ways to wake waiters:
| Wakes | Typical name |
|---|
| Single wake | Exactly one waiting thread (implementation picks which) | notify() / signal() |
| Broadcast wake | Every thread currently waiting on that condition variable | notifyAll() / broadcast() |
Use notify/signal when: every thread waiting on this condition variable is waiting for the same predicate, and making that predicate true for one thread is enough — waking a second thread would just be wasted work, since it would recheck the same predicate, find it false again, and go right back to sleep. A single-producer/single-consumer-shape queue with two separate condition variables (notFull, notEmpty) is the textbook case: incrementing available space only ever helps a producer, so signaling notFull and picking any one waiting producer is both correct and efficient.
Use notifyAll/broadcast when: multiple threads share one condition variable but are actually waiting on different predicates over the same state (common when a language only gives you one wait-set per lock, like Java's intrinsic synchronized/wait/notifyAll), or when you simply can't cheaply determine which specific waiter's predicate just became true. Broadcasting is always safe — every woken thread re-checks its own guard in its own while loop and simply goes back to sleep if its condition still isn't met — it's just potentially wasteful, since n threads might wake, contend for the lock one at a time, and n-1 of them immediately re-block. This wasted churn is called the thundering herd problem, and it's the cost you're explicitly trading against correctness risk when you pick notify over notifyAll.
The practical decision rule interviewers want to hear: default to the broadcast form whenever you're not certain every waiter is interchangeable; only narrow to the single-wake form once you can prove it (usually because you've split into multiple condition variables, one per distinct predicate). Getting this wrong in the unsafe direction — using notify() when waiters have different predicates — doesn't crash loudly; it silently drops a thread into a wait it will never wake from, because the implementation happened to pick the "wrong" waiter and no one else will ever signal again. That failure mode, and how to reason about it rigorously, is exactly where the next subtopic picks up.