Concurrency Patterns
Monitor Pattern
An object that lets only one thread inside at a time — with condition variables for coordination
A monitor is an object whose methods are mutually exclusive — only one thread runs any monitor method at a time. Implicit lock + condition variables. Java synchronized, C# lock. Hoare 1974.
- InventedTony Hoare, 1974
- Mutual exclusionImplicit per-object lock
- Coordinationwait() / notify() / notifyAll()
- SemanticsMesa (modern langs) vs Hoare (theoretical)
- Lock acquire cost (JVM)~20-50 ns uncontended
- Famous inJava synchronized, C# lock, Python RLock
Interactive visualization
Threads queue at a monitor's gate. Only one is inside at a time. Wait/signal moves them through the condition queue.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
How a monitor works
Tony Hoare introduced monitors in 1974 as a higher-level alternative to bare mutexes. The idea: package the lock with the data it protects, and let the language give every method on that object automatic mutual exclusion. Threads enter the monitor one at a time; the rest queue at the gate.
A monitor has three parts:
- Encapsulated state. Data that's accessed only via monitor methods.
- Mutual exclusion. An implicit lock — entering any monitor method acquires it; leaving releases it. Only one thread is inside the monitor at any moment.
- Condition variables. Queues a thread can wait on when the monitor's state isn't yet ready for it. wait() atomically releases the lock and suspends; signal() wakes one waiter; signalAll() wakes them all.
In Java, every object is a monitor. The synchronized keyword acquires the object's intrinsic lock on entry and releases on exit. wait(), notify(), and notifyAll() operate on the object's single intrinsic condition variable. C# has the same model with lock and Monitor.Wait/Pulse. Python's threading.RLock and threading.Condition compose into the same pattern.
The killer example: a bounded blocking queue. put() waits when the queue is full; take() waits when it's empty; each signals the other on success. A few dozen lines of monitor code produces a thread-safe primitive that's the backbone of every thread pool.
Anatomy of monitor operations
Three operations matter:
- Enter / exit. Acquire the monitor's lock on entry; release on exit. In Java this happens implicitly at the boundary of a
synchronizedblock. Uncontended cost is ~20-50 nanoseconds on modern JVMs thanks to biased locking and thin-lock optimizations. - wait(). Called inside the monitor. Atomically: release the lock, add this thread to the condition's wait queue, suspend. When the thread is later signaled, it re-acquires the lock before returning. The "atomically" part is the magic — without it, a signal sent between the release and the suspend would be missed.
- signal() / notify(). Called inside the monitor by a thread that knows the awaited condition might now be true. Wakes one (or all, with notifyAll) waiting threads. The signaling thread keeps the lock; the woken thread joins the entry queue.
The flow for a producer-consumer bounded queue:
monitor BoundedQueue:
state: items[], capacity
cv: not_full, not_empty
method put(item):
while items.size == capacity: wait(not_full)
items.append(item)
signal(not_empty)
method take():
while items.is_empty: wait(not_empty)
item = items.pop_front()
signal(not_full)
return item
The while loop around wait is non-negotiable on Mesa-semantics languages — see the FAQ.
Hoare vs Mesa semantics
Two flavors of monitor exist in the literature.
Hoare semantics (original, 1974). When a thread signals a condition, the lock is transferred directly to the awoken thread. The signaling thread is suspended until the awoken thread releases the monitor. The awoken thread can therefore assume the condition still holds — there's no race between signal and wake. Theoretically clean, practically expensive (the implementation needs a "signaler queue" too).
Mesa semantics (Lampson and Redell, Xerox PARC, 1980). signal() merely makes a waiting thread runnable; the signaler keeps the lock and continues. The awoken thread joins the regular entry queue and re-competes for the lock. By the time it acquires the lock, other threads may have entered the monitor and changed state — so the awoken thread cannot assume the condition still holds. It must recheck.
Nearly every modern language uses Mesa semantics: Java, C#, Python, POSIX. The practical consequence is the cardinal rule of monitor programming: always wait inside a while loop that checks the condition. Code like if (!ready) wait(); is broken under Mesa; while (!ready) wait(); is correct under both.
When to use the monitor pattern
- Encapsulated thread-safe data structures. Bounded queues, thread-safe collections, in-memory caches. Wrap the data in a monitor; expose only synchronized methods.
- Producer-consumer coordination. Multiple producers feeding multiple consumers through a shared queue — the classic monitor exercise.
- Resource pools. Connection pools, object pools — wait for a free resource, signal on release.
- Reader-writer coordination. Implementing a custom ReadWriteLock often starts as a monitor with multiple conditions (one for readers, one for writers).
- State machines with thread-safe transitions. Encode states inside the monitor; thread requests update the state under the lock.
Avoid monitors for distributed coordination (use consensus algorithms), for lock-free data structures (use atomics + memory ordering), and for very-fine-grained shared variables (atomics will outperform).
Monitor vs related primitives
| Primitive | What it provides | Strengths | Weaknesses |
|---|---|---|---|
| Monitor | Mutual exclusion + condition variables, bundled with data | High-level, language-integrated | Per-object lock granularity may be coarse |
| Mutex (raw lock) | Mutual exclusion only | Minimal, composable | No coordination — needs condvar separately |
| Semaphore | Counting permits | Resource counting, no thread coupling | No wait-on-condition, no encapsulation |
| Reader-Writer Lock | Many readers OR one writer | Higher throughput for read-heavy | More complex; can starve writers |
| Atomics | Lock-free CAS / fetch-add | Fastest for tiny ops | Hard to compose, ABA hazards |
| Channels | Message passing | Data shared by communication | Less efficient for tight in-process sharing |
Pseudo-code: bounded queue monitor
class BoundedQueue:
def __init__(self, capacity):
self.items = []
self.cap = capacity
self.lock = Lock()
self.not_full = Condition(self.lock)
self.not_empty = Condition(self.lock)
def put(self, item):
with self.lock:
while len(self.items) >= self.cap:
self.not_full.wait()
self.items.append(item)
self.not_empty.notify() # at most one consumer needs to wake
def take(self):
with self.lock:
while not self.items:
self.not_empty.wait()
item = self.items.pop(0)
self.not_full.notify()
return item
Java implementation
// Classic monitor using the intrinsic lock + wait/notify.
public class BoundedQueue<T> {
private final Object[] items;
private int head = 0, tail = 0, count = 0;
public BoundedQueue(int capacity) {
items = new Object[capacity];
}
public synchronized void put(T item) throws InterruptedException {
while (count == items.length) {
wait(); // releases monitor, waits
}
items[tail] = item;
tail = (tail + 1) % items.length;
count++;
notifyAll(); // wake any take() waiters
}
@SuppressWarnings("unchecked")
public synchronized T take() throws InterruptedException {
while (count == 0) {
wait();
}
T item = (T) items[head];
items[head] = null;
head = (head + 1) % items.length;
count--;
notifyAll(); // wake any put() waiters
return item;
}
}
// Modern Java prefers explicit Lock + Condition for finer control.
import java.util.concurrent.locks.*;
public class BoundedQueueV2<T> {
private final Object[] items;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
// ... separate signal() of notFull / notEmpty avoids waking the wrong waiters.
}
C# implementation
public class BoundedQueue<T> {
private readonly Queue<T> items = new();
private readonly int capacity;
private readonly object gate = new(); // monitor
public BoundedQueue(int capacity) { this.capacity = capacity; }
public void Put(T item) {
lock (gate) {
while (items.Count == capacity) {
Monitor.Wait(gate); // releases lock, waits
}
items.Enqueue(item);
Monitor.PulseAll(gate); // wake all waiters
}
}
public T Take() {
lock (gate) {
while (items.Count == 0) {
Monitor.Wait(gate);
}
T item = items.Dequeue();
Monitor.PulseAll(gate);
return item;
}
}
}
Common pitfalls
- if instead of while around wait(). Under Mesa semantics, the condition may have changed between signal and re-acquire. Always recheck in a while loop. This is the #1 monitor bug.
- notify() when notifyAll() is required. If multiple waiters are blocked on different conditions on the same monitor, notify() may wake the wrong one — the right thread never gets the signal. Use notifyAll() unless you can prove only one kind of waiter exists.
- Forgetting to hold the lock when calling wait/notify. All wait/notify variants must be called while holding the monitor's lock. Calling without it throws IllegalMonitorStateException in Java.
- Nested locking and deadlock. Thread A holds monitor X and calls a method on monitor Y; thread B holds Y and wants X. Classic circular wait. Avoid nested locks, or always acquire in a strict global order.
- Waiting forever. If no thread ever signals, the waiter blocks forever. Use timed wait (
wait(ms)) when missed signals are conceivable. - Sharing a single condition variable for distinct conditions. Java's intrinsic monitor has only one condition. Prefer
ReentrantLock.newCondition()when you need separate "not full" and "not empty" queues — saves spurious wakeups. - Lock granularity too coarse. A monitor that locks the whole object serializes everything. For hot data, finer-grained locking or lock-free structures often pay off.
Performance
On modern JVMs, an uncontended synchronized entry costs about 20-50 nanoseconds — almost free thanks to biased and thin locking optimizations. Contended entries (multiple threads competing) cost 100-1000 nanoseconds depending on the lock-acquisition path, with worst case requiring a syscall to suspend the thread. A typical monitor operation (acquire, modify a small field, release) takes 50-200 nanoseconds end-to-end on the JVM.
wait() is more expensive — the thread is parked at the OS level, which costs 1-5 microseconds for park plus another 1-5 microseconds to wake. For high-throughput scenarios, consider lock-free queues (LMAX Disruptor, JCTools MpmcArrayQueue) which avoid park/unpark entirely and can sustain hundreds of millions of ops per second.
The monitor pattern's real value isn't raw speed — it's correctness. The price of a bug in shared-state concurrency is often hours of intermittent crashes; the price of a slow lock is a few percent of throughput. Get the monitor pattern right first, then optimize the hot paths only after profiling.
Frequently asked questions
What is the difference between a monitor and a mutex?
A mutex is a primitive lock — acquire, do critical section, release. A monitor is a higher-level abstraction: it bundles the lock with the data it protects, and it adds condition variables for waiting on application-level conditions (e.g. 'wait until the queue is non-empty'). In Java, every object has an intrinsic monitor — its lock plus its single condition variable accessed via wait/notify. A monitor is built on a mutex but is a more complete pattern.
What is a condition variable and why do I need one?
A condition variable lets a thread holding the monitor lock atomically release the lock and wait until some condition becomes true. Without it, a thread that needs to wait for state (queue not empty, buffer has space) would have to spin-poll with the lock held, blocking every other thread. The wait() call releases the lock while sleeping and re-acquires it on wake — atomic enough that the thread cannot miss a signal sent while it was suspending.
Hoare vs Mesa semantics — which does my language use?
Mesa semantics, in nearly all modern languages. With Hoare semantics, signal() transfers the monitor lock directly to a waiting thread, which can assume the condition still holds. With Mesa semantics (Java, C#, Python, POSIX), signal() just makes a waiting thread runnable — the signaler keeps the lock, and the waiter may not get it for a while. By then the condition might have changed, which is why wait() must always be called in a while-loop checking the condition.
Why does wait() have to be inside a while loop?
Mesa semantics plus spurious wakeups. Even if no thread called notify(), POSIX condition variables can wake a waiting thread for implementation reasons — the spec explicitly permits it. And even after a real notify, the condition might have been satisfied by some other thread between wake and re-acquire. Wrapping wait() in 'while (!condition) { wait(); }' makes the code correct under both.
What's the difference between notify() and notifyAll()?
notify() wakes one arbitrary waiting thread. notifyAll() wakes every waiting thread. Use notifyAll() when threads might be waiting on different conditions on the same monitor, or when you can't be sure which waiting thread should win. Use notify() only when all waiters are interchangeable and you're sure the single woken thread can make progress. notifyAll() is safer but adds spurious wakeup work proportional to the number of waiters.
Is the monitor pattern still useful with modern concurrency abstractions?
Yes for in-process thread coordination — bounded queues, semaphores, barriers all use monitors under the hood. Less so for distributed coordination or async I/O — those favor message-passing, channels, or async primitives. Modern code often uses higher-level abstractions (java.util.concurrent's BlockingQueue, Semaphore, CountDownLatch) which encapsulate the monitor pattern so you don't write wait/notify directly. The pattern is foundational.
Can the monitor pattern deadlock?
Yes. Two classic ways: nested monitor lock (thread A holds monitor X and calls method on monitor Y; thread B holds Y and wants X — circular wait, deadlock); and waiting forever (a thread waits on a condition but no other thread ever signals). Avoid nested locking, or always acquire locks in a consistent order. Use timed wait (wait(timeoutMs)) instead of unbounded wait when missed signals are possible.