multithreading
producer-consumer
concurrent programming
thread synchronization
parallel processing

Producer/consumer multithreading

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

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:

  1. Only one thread mutates the shared buffer state at a time.
  2. Producers wait when the buffer is full.
  3. 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.

python
1import queue
2import threading
3import time
4
5work_queue = queue.Queue(maxsize=3)
6
7
8def producer(name: str, count: int) -> None:
9    for i in range(count):
10        item = f"job-{name}-{i}"
11        work_queue.put(item)  # blocks if the queue is full
12        print(f"produced: {item}")
13        time.sleep(0.1)
14
15
16def consumer(name: str) -> None:
17    while True:
18        item = work_queue.get()  # blocks if the queue is empty
19        if item is None:
20            work_queue.task_done()
21            print(f"{name}: shutting down")
22            return
23
24        print(f"{name} processing {item}")
25        time.sleep(0.2)
26        work_queue.task_done()
27
28
29consumer_thread = threading.Thread(target=consumer, args=("worker-1",))
30producer_thread = threading.Thread(target=producer, args=("p1", 5))
31
32consumer_thread.start()
33producer_thread.start()
34
35producer_thread.join()
36work_queue.put(None)  # sentinel value
37work_queue.join()
38consumer_thread.join()

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:

python
1import threading
2from collections import deque
3
4buffer = deque()
5capacity = 2
6condition = threading.Condition()
7
8
9def produce(item: int) -> None:
10    with condition:
11        while len(buffer) >= capacity:
12            condition.wait()
13        buffer.append(item)
14        print(f"added {item}")
15        condition.notify_all()
16
17
18def consume() -> int:
19    with condition:
20        while not buffer:
21            condition.wait()
22        item = buffer.popleft()
23        print(f"removed {item}")
24        condition.notify_all()
25        return item

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:

  1. How do workers stop?
  2. 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
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track 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.

Browse interview questions

All Rights Reserved.