Concurrency
Producer–consumer problem: interview questions and how to answer them
The producer–consumer pattern decouples work creation from work processing through a bounded buffer whose full and empty states must be synchronised.
Written and reviewed by Sahil Srivastav
What it actually is
Producers place items into a queue and consumers remove them. The buffer is a shared state machine: producers must wait when it is full, consumers when it is empty, and both must wake the right waiters after changing the state.
A correct solution preserves three invariants: each item is enqueued once, each item is dequeued once, and no thread observes a half-written queue state. A condition variable belongs with the mutex protecting the predicate; a notification alone is not a stored event.
Why it matters in production
Unbounded queues turn a downstream outage into heap growth. Uncoordinated checks lose wakeups or permit two consumers to remove the same slot. A bounded queue supplies backpressure and makes the producer experience the actual capacity of the consumer.
This pattern is the foundation of logging, job workers, upload pipelines, and broker consumers, so its failure mode tends to become a whole-service outage.
How it works
The predicate loop
Wait in `while (queue.empty())`, not `if`: a wakeup does not prove the predicate is true, and another consumer may win before the awakened thread reacquires the mutex.
Bounded capacity
Capacity is part of the contract. When full, block, reject, or shed according to the value of the item; never silently allocate forever.
Shutdown
A closed flag must be checked by both wait predicates. Consumers need a way to distinguish “empty for now” from “empty and permanently closed”.
Fairness
A condition variable does not promise FIFO service. If starvation matters, use a fair queue or explicit admission policy and measure wait time.
Implementing it
Use a library blocking queue where possible; it packages the lock and condition protocol. Put timeouts on `put` and `take` when an item has a deadline.
Count enqueue, dequeue, rejection, queue age, and consumer lag. A queue depth without age cannot distinguish a healthy burst from a stuck consumer.
Drain or persist durable work during shutdown; dropping the in-memory tail should be an explicit policy.
synchronized (lock) {
while (queue.size() == capacity && !closed) lock.wait();
if (closed) throw new ClosedQueueException();
queue.add(item);
lock.notifyAll();
}Interview questions and how to answer them
Why is the wait condition a loop?
Wakeups are hints, not ownership of the condition. A spurious wakeup or a competing consumer can leave the queue empty, so the awakened thread must recheck while holding the mutex.
What happens when the buffer is full?
The producer must block, reject, or shed based on the item’s value and deadline. Blocking without a bound simply moves the unbounded queue into producer threads.
How should shutdown work?
Mark the queue closed under the same lock, wake all waiters, and have consumers drain or stop according to the contract. A consumer must not wait forever after the producer has gone away.
Why is `notify` sometimes insufficient?
If different predicates share one condition, waking one arbitrary waiter may wake a producer when only consumers can make progress. Separate conditions or `notifyAll` avoids leaving eligible work asleep.
Answers that lose the round
- Checking the queue outside the lock
- Using `if` around `wait`
- Calling `notify` without changing the predicate
- Using an unbounded queue as a backpressure strategy
- Having no close or cancellation state
- Assuming wakeups are fair
FAQ
Is a message broker still producer–consumer?
Yes, with durability, acknowledgement, and often partitioning added to the in-memory pattern.
Should consumers poll?
Polling can be appropriate with a long interval or broker pull model, but a local queue should wait on a condition rather than spin.
Can a queue guarantee exactly-once processing?
No. It can coordinate ownership; durable processing still needs acknowledgement and idempotency.