Producer/consumer multithreading
Interview Questions practice on Codemia
Over 8,000 real interview questions from top companies, searchable by company and role.
Introduction
The producer-consumer pattern shows up anywhere one part of a system generates work and another part processes it. Logging pipelines, job queues, network servers, and video processing systems all use some form of it. The hard part is not moving data between threads; it is coordinating access so work is not lost, duplicated, or read before it exists.
Why the Pattern Needs Coordination
A producer thread creates items and places them into a shared buffer. A consumer thread removes those items and handles them. If both threads touch the same data structure without synchronization, you get race conditions.
A correct design has three basic rules:
- Only one thread mutates the shared buffer state at a time.
- Producers wait when the buffer is full.
- Consumers wait when the buffer is empty.
That is why producer-consumer examples usually use a queue plus blocking coordination primitives such as mutexes, semaphores, or condition variables. The queue stores the work. The synchronization mechanism controls when each thread may proceed.
A Simple Blocking Queue Example
In Python, queue.Queue already implements the necessary locking and waiting behavior. That makes it a good reference model because the code focuses on the pattern instead of low-level thread bookkeeping.
This example matters for two reasons. First, the queue blocks automatically when full or empty, so there is no busy waiting. Second, the sentinel value lets the consumer stop cleanly instead of hanging forever waiting for more work.
What Happens Under the Hood
If you build the same pattern from lower-level primitives, you usually combine:
- a mutex to protect the queue itself
- a condition variable to signal state changes
- optional counters for capacity tracking
The producer locks the queue, checks whether there is room, waits if necessary, inserts an item, and signals consumers. The consumer locks the queue, checks whether an item exists, waits if necessary, removes an item, and signals producers. That handoff is what prevents lost wakeups and inconsistent buffer state.
Here is the same idea with threading.Condition so the coordination is visible:
For real applications, a built-in blocking queue is usually the better choice. It is shorter, easier to review, and less likely to hide subtle synchronization bugs.
Choosing Buffer Size and Thread Count
A common mistake is treating the queue as an implementation detail instead of a tuning control. Buffer size changes system behavior.
A very small buffer creates backpressure quickly. That is useful when you want to stop producers from getting too far ahead and consuming memory. A larger buffer can smooth short bursts, but it also increases latency and can hide overload until the queue becomes huge.
Thread count also needs to match the workload:
- CPU-bound consumers often benefit more from process-level parallelism than more threads, depending on the runtime.
- I/O-bound consumers usually benefit from multiple worker threads because they spend time waiting on disk or network operations.
- Too many workers increase context switching and make debugging harder.
The pattern is therefore not just about correctness. It is also a throughput and resource-management tool.
Graceful Shutdown and Work Completion
A production-quality implementation must answer two operational questions:
- How do workers stop?
- How do you know all work finished?
Sentinel values, cancellation tokens, or explicit shutdown events are common answers for stopping. Queue acknowledgments such as task_done() and join() are useful when you need the main thread to wait until every queued item has been processed.
If you skip shutdown design, the code may appear correct during normal runs but hang during tests, deployments, or service restarts.
Common Pitfalls
The most common bug is reading or writing a shared list without a lock and assuming the race is rare enough to ignore. That fails under load.
Another problem is busy waiting, where a consumer repeatedly checks whether data exists instead of blocking. That wastes CPU and usually signals that the wrong primitive was chosen.
A third issue is forgetting termination logic. If producers exit and consumers still call get() forever, the program never shuts down cleanly.
Finally, do not hold a lock while doing expensive work. Remove the item from the queue while locked, then release the lock and process it. Otherwise, one slow consumer blocks the entire pipeline.
Summary
- Producer-consumer is a coordination pattern for moving work safely between threads.
- The buffer stores work; locks and condition mechanisms protect access to it.
- Prefer a built-in blocking queue when the language runtime provides one.
- Buffer size affects backpressure, latency, and memory usage.
- Always design shutdown behavior explicitly with sentinels, events, or cancellation signals.
- Keep critical sections small so workers do not block one another unnecessarily.
Related reading
- Programmatically determine which Java thread holds a lock
- Promise is blocking the thread
- promise.all inside a forEach loop — everything firing at once
- Promises - How to make asynchronous code execute synchronous without async / await?
- Proper handling of context data in libaio callbacks?
- Proper request with async/await in Node.JS
- Proper use of mutexes in Python
- Proper way of getting several scripts asynchronously using Jquery with post-document-ready callback
.png&w=3840&q=75)
Tackling System Design Interview Problems
A short course that equips you with the skills to approach system design interviews methodically.
Start the free courseTrack what you have practised
A free account saves your progress, solutions and study plan across every problem on Codemia.
Interview Questions practice on Codemia
Over 8,000 real interview questions from top companies, searchable by company and role.