Producer-Consumer Patterns, Queues and Channels
A conveyor belt between two workstations lets each work at its own pace: one side puts items on, the other takes them off.
Learning Objectives
Solve the producer-consumer problem with bounded buffers; understand unbounded queues and memory growth.
Use blocking and non-blocking queues, work queues, task submission and result collection.
Use channels: send/receive semantics, buffered versus rendezvous, close semantics, select/multiplexing.
Apply backpressure, capacity limits and admission control; shut down with poison pills (knowing the caveats); distribute work (work stealing, fan-out/fan-in, pipelines, map/reduce, batching).
Reason about message ownership and copying, fairness, cancelling blocked producers/consumers, and testing queues.
Plain-Language Explanation
A conveyor belt between two workstations lets each work at its own pace: one side puts items on, the other takes them off. If the belt fills up, the producer must wait (or the system must refuse new orders); if it empties, the consumer waits. Queues and channels are software conveyor belts. They are the safest way for threads to cooperate because each item is handed over — only one side owns it at a time — instead of being shared.
Precise Technical Explanation
Producers create work items; consumers process them; a shared buffer decouples their rates. Correctness: every item is consumed exactly once; producers wait (or are refused) when the buffer is full; consumers wait when it is empty; shutdown completes cleanly.
A fixed-capacity buffer (ring buffer + mutex + two condition variables, or a semaphore pair). Its capacity is the system's shock absorber and its backpressure signal.
Unbounded queues and memory growth If producers are faster than consumers on average, an unbounded queue grows until memory runs out — latency rises without limit first (every item waits behind all earlier ones). Unbounded queues hide overload rather than handling it.
put blocks when full, take blocks when empty: queue.Queue(maxsize), BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue), .NET BlockingCollection/Channel<T>, Go buffered channels.
Operations return immediately with success/failure (put_nowait raising queue.Full, offer/poll, TryWrite/TryRead), or lock-free concurrent queues (ConcurrentLinkedQueue, ConcurrentQueue<T>) that never block but are unbounded.
A queue of tasks consumed by a pool of workers — the core of every thread pool (Module 15).
Task submission and result collection Submit returns a handle (future) or results are sent back on a second queue/channel tagged with the task's identity.
A channel is a typed conduit with send and receive; in CSP-style languages (Go, Kotlin, Rust std::sync::mpsc/crossbeam, .NET System.Threading.Channels) it is the main communication mechanism.
Send/receive semantics A send hands a value to the channel; a receive takes the next value. In Go a send *happens-before* the corresponding receive completes, so anything written before the send is visible after the receive.
Buffered versus rendezvous channels An unbuffered (rendezvous) channel transfers a value only when sender and receiver meet — the send blocks until a receiver takes it. A buffered channel of capacity *n* lets senders run ahead by up to *n* items.
Channel close semantics Closing signals "no more values". Receivers drain buffered values and then receive a closed indication (v, ok:= <-ch gives ok == false; for v:= range ch ends). Sending on a closed channel panics in Go. Rule of thumb: the *sender* closes, and only one party closes.
Select/multiplexing over channels select (Go), Select/WaitToReadAsync patterns (.NET), crossbeam::select! (Rust), kotlinx.coroutines select: wait on several channel operations and timers at once, proceeding with whichever is ready.
The mechanism by which a slow consumer slows producers down: blocking puts, refusing work, or explicit demand signalling (reactive streams' request(n)). Without backpressure, overload turns into unbounded queues, then out-of-memory.
Queue capacity and admission control Choose capacity from the latency budget (by Little's Law, Module 22: wait ≈ queue length / throughput) and refuse work beyond it (HTTP 429/503, RejectedExecutionException) — fast failure beats slow failure.
Poison-pill shutdown patterns and caveats Enqueue a sentinel (None, a special object) per consumer; a consumer exits when it takes one. Caveats: you need exactly one pill per consumer; pills queue behind all existing work (slow shutdown); with priority queues pills may be reordered; a crashed consumer leaves an unconsumed pill. Closing a channel or a cancellation token is often cleaner.
Each worker has its own deque; idle workers steal from the tail of others'. Reduces contention on one shared queue and balances load automatically (Module 15).
Round-robin, least-loaded, hashing by key (keeps per-key ordering), or a shared queue (natural load balancing).
Fan-out and fan-in Fan-out: several workers read from one channel to parallelise. Fan-in: merge several channels into one.
Stages connected by channels, each stage running concurrently (parse → transform → write). Throughput is limited by the slowest stage; bounded channels between stages provide backpressure.
Map/reduce-style processing Map items independently in parallel, then reduce partial results — no shared mutable state until the (single-threaded or tree-shaped) reduction.
Taking several items per dequeue or writing several per I/O amortises per-item overhead (locks, system calls, network round-trips) at the cost of latency.
Message ownership and copying Sending a value transfers ownership: the sender must not mutate it afterwards. Some systems copy messages (processes, Web Workers' structured clone, Erlang), some move them (Rust), some pass references by convention (Go, Java) — then mutation after sending is a data race.
FIFO queues are fair to items, not necessarily to producers or consumers; a fast producer can monopolise capacity. Per-tenant queues or weighted fair queuing prevent one client from starving others.
Cancellation of blocked producers/consumers Blocked put/take calls must be interruptible: timeouts, a closed channel, a cancellation token, or select with a done channel.
Testing queue correctness Stress tests with many producers/consumers checking that the multiset of consumed items equals the produced multiset, that no item is lost or duplicated, ordering per producer is preserved if promised, and shutdown terminates.
Why It Matters in Real Systems
Message queues decouple components in every large system — logging pipelines, job systems, request handling, stream processing. Most overload incidents involve a queue without a bound or a consumer that silently stopped.
Key Terms
Slowing or refusing producers when consumers lag Unbuffered: sender and receiver meet
Sentinel item that tells a consumer to stop Fan-out / fan-in
Distribute to / merge from several workers Chain of concurrent stages Refusing work beyond capacity
Idle workers take work from busy workers' deques
Syntax and API Overview
The specification's Python queue example (CPython 3.13.1):
Output (three real runs — they happened to print in the same order here, but nothing in the program guarantees it):
The sentinel None (a poison pill) is enqueued once per worker, after the real items, so each worker exits after the work ahead of it is done. task_done() is in finally so that jobs.join() — which waits until every put has a matching task_done — cannot hang if processing raises. Print order is not guaranteed: two workers take items concurrently. Production code should catch and report per-item exceptions (or put them on a result queue) rather than letting a worker die, and should use bounded queues and cancellation.
from queue import Queue
from threading import Thread
jobs = Queue()
def worker():
while True:
item = jobs.get()
try:
if item is None:
return
print(f"processed: {item}")
finally:
jobs.task_done()
threads = [Thread(target=worker) for _ in range(2)]
for thread in threads:
thread.start()
for item in ["A", "B", "C"]:
jobs.put(item)
for _ in threads:
jobs.put(None)
jobs.join()
for thread in threads:
thread.join()--- run 1 ---
processed: A
processed: B
processed: C
--- run 2 ---
processed: A
processed: B
processed: C
--- run 3 ---
processed: A
processed: B
processed: CComplete Code Example
Channels in Go 1.23.4: rendezvous vs buffered, close and range, select with a timeout, and a fan-out/fan-in pipeline whose result is deterministic:
Backpressure policies with a bounded .NET 10 Channel<T> (blocking/waiting, dropping, rejecting):
package main
import (
"fmt"
"sort"
"sync"
"time"
)
func main() {
// 1. rendezvous: the send completes only when the receiver takes the value
messages := make(chan string)
go func() { messages <- "hello from worker" }()
fmt.Println(<-messages)
// 2. buffered channel: sends proceed until the buffer (2) is full; close + range drains it
buf := make(chan int, 2)
buf <- 1
buf <- 2
fmt.Println("buffered: len", len(buf), "cap", cap(buf))
close(buf)
for v := range buf {
fmt.Println("drained", v)
}
v, ok := <-buf
fmt.Println("receive from closed, empty channel:", v, ok)
// 3. select with a timeout
slow := make(chan string)
go func() { time.Sleep(200 * time.Millisecond); slow <- "late" }()
select {
case m := <-slow:
fmt.Println("got", m)
case <-time.After(50 * time.Millisecond):
fmt.Println("select: timed out after 50ms")
}
// 4. pipeline: generate -> fan-out to 3 squarers -> fan-in -> sum
gen := func(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // the sender closes
for i := 1; i <= n; i++ {
out <- i
}
}()
return out
}
square := func(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer
…hello from worker
buffered: len 2 cap 2
drained 1
drained 2
receive from closed, empty channel: 0 false
select: timed out after 50ms
fan-in results (sorted): [1 4 9 16 25 36 49 64 81 100] sum 385using System;
using System.Threading.Channels;
using System.Threading.Tasks;
static async Task Demo(BoundedChannelFullMode mode)
{
var ch = Channel.CreateBounded<int>(new BoundedChannelOptions(3) { FullMode = mode });
int accepted = 0;
for (int i = 1; i <= 6; i++)
if (ch.Writer.TryWrite(i)) accepted++; // never blocks; behavior when full depends on FullMode
ch.Writer.Complete();
var kept = new System.Collections.Generic.List<int>();
await foreach (var x in ch.Reader.ReadAllAsync()) kept.Add(x);
Console.WriteLine($"{mode,-10} TryWrite accepted {accepted}/6, consumer received [{string.Join(", ", kept)}]");
}
foreach (var mode in new[] { BoundedChannelFullMode.Wait, BoundedChannelFullMode.DropOldest, BoundedChannelFullMode.DropNewest, BoundedChannelFullMode.DropWrite })
await Demo(mode);
// Wait mode with WriteAsync: the producer is suspended until the consumer makes room
var bounded = Channel.CreateBounded<int>(2);
var producer = Task.Run(async () =>
{
for (int i = 1; i <= 5; i++)
{
await bounded.Writer.WriteAsync(i); // awaits while the channel is full
Console.WriteLine($" produced {i}");
}
…Wait TryWrite accepted 3/6, consumer received [1, 2, 3]
DropOldest TryWrite accepted 6/6, consumer received [4, 5, 6]
DropNewest TryWrite accepted 6/6, consumer received [1, 2, 6]
DropWrite TryWrite accepted 6/6, consumer received [1, 2, 3]
produced 1
produced 2
(producer is now waiting: channel full)
consumed 1
produced 3
consumed 2
produced 4
consumed 3
produced 5
consumed 4
consumed 5Line-by-Line Explanation
Go 1: the anonymous goroutine's send blocks until main receives — a rendezvous that also synchronises memory.
Go 2: two buffered sends succeed immediately; after close, range drains the buffer and stops; a further receive returns the zero value and ok == false.
Go 3: select takes whichever case is ready first; the timer wins.
Go 4: gen closes its output when done; three square stages read the *same* channel (fan-out, each value goes to exactly one); merge closes its output only after a WaitGroup confirms all inputs are drained (fan-in). Arrival order varies, so results are sorted before printing.
C#: TryWrite never waits. Wait mode rejects writes when full (TryWrite returns false; WriteAsync would wait); DropOldest evicts the oldest buffered item; DropNewest evicts the newest buffered item; DropWrite silently discards the incoming item but reports success.
The second C# section shows true backpressure: WriteAsync suspends the producer until the consumer reads.
Output
Shown under each program. In the second .NET section the interleaving of "produced"/"consumed" lines can vary slightly between runs; the first section is deterministic.
Interleaving or State Trace
Bounded channel (capacity 2) with WriteAsync:
WriteAsync(3) suspends reads 1 → room resumes, writes 3
Correctness and Safety Analysis
Each item is owned by exactly one party at a time: the producer until the send, the channel, then one consumer. No locks appear in user code; the queue/channel implementations provide mutual exclusion and memory publication. The Go pipeline's invariants: every channel is closed exactly once, by its sole sender; merge closes only after all inputs are exhausted.
Common Mistakes and Misconceptions
Unbounded queues "just in case".
Closing a Go channel from the receiver side, or twice (panic).
One poison pill for several consumers.
Forgetting task_done() on error paths (Python join() hangs).
Mutating a message after sending it by reference.
Assuming consumers see items in production order when several consumers run.
Failure Modes and Debugging Steps
Queue grows forever: consumers slower than producers or a consumer died — monitor queue length and consumer liveness; add bounds and alerting.
Hang at shutdown: missing sentinel/close, or a producer blocked on a full queue that nobody drains — cancel producers, then close.
Goroutine leak: a sender blocked forever on a channel nobody reads — use context or buffered result channels (Module 13).
Testing a queue under stress — four producers, three consumers, verifying exactly-once delivery (CPython 3.13.1):
import queue
import threading
from collections import Counter
q = queue.Queue(maxsize=8) # bounded: producers block when it is full
PRODUCERS, CONSUMERS, PER = 4, 3, 5000
consumed = Counter()
lock = threading.Lock()
def producer(pid):
for i in range(PER):
q.put((pid, i))
def consumer():
local = Counter()
while (item := q.get()) is not None:
local[item] += 1
with lock:
consumed.update(local)
cons = [threading.Thread(target=consumer) for _ in range(CONSUMERS)]
prods = [threading.Thread(target=producer, args=(p,)) for p in range(PRODUCERS)]
for t in cons + prods:
t.start()
for t in prods:
t.join()
for _ in cons:
q.put(None) # one poison pill per consumer, after all real items
for t in cons:
t.join()
expected = {(p, i) for p in range(PRODUCERS) for i in range(PER)}
print("items consumed:", sum(consumed.values()), "expected:", len(expected))
print("lost:", len(expected - set(consumed)), "duplicated:", sum(1 for c in consumed.values() if c > 1))items consumed: 20000 expected: 20000
lost: 0 duplicated: 0Performance and Scalability Implications
A single shared queue serialises all producers and consumers on its lock — fine up to hundreds of thousands of operations per second, a bottleneck beyond. Batching, sharded queues, lock-free queues and work stealing scale further. Larger buffers smooth bursts but add latency.
Security Considerations
Unbounded queues fed by external requests are a memory-exhaustion DoS vector; per-client limits prevent one client from filling shared capacity. Validate messages at trust boundaries (a queue is not a security boundary).
Platform and Runtime Caveats
Python queue.Queue is for threads; asyncio.Queue for coroutines; multiprocessing.Queue for processes — they are not interchangeable..NET DropWrite reports success for discarded items; Wait mode makes TryWrite return false when full.
Go channels are not thread-safe *to close concurrently*; len(ch) is a snapshot.
Java LinkedBlockingQueue is optionally bounded — the default capacity is Integer.MAX_VALUE.
Real-World Applications
Logging and telemetry pipelines, job queues (Sidekiq, Celery), web server accept queues, audio/video pipelines, ETL stages, Go microservices built from goroutines and channels, Kafka/RabbitMQ at the distributed level (Module 20).
Best Practices
Bound every queue and decide the full-queue policy explicitly (wait, drop, reject).
Let the single sender close; signal shutdown with close or cancellation rather than ad-hoc pills when possible.
Monitor queue length, age of oldest item and consumer liveness.
Treat sent messages as owned by the receiver.
Stress-test exactly-once delivery and shutdown.
Exercises
E1 · Beginner. Why does jobs.join() in the specification example not wait forever, and what would make it do so?
Answer: Every get() (including the sentinels) is matched by task_done() in finally. If a worker skipped task_done() — e.g. by returning before the finally existed — the unfinished-task count would never reach zero.
E2 · Intermediate. A producer makes 1,000 items/s and a consumer handles 800/s. What happens with an unbounded queue after one minute, and with a bounded queue of 100 that blocks?
Answer: Unbounded: the queue grows by 200/s — 12,000 items after a minute, and latency grows to ~15 s. Bounded/blocking: after half a second the queue is full and the producer is throttled to 800/s; latency stays at ~0.125 s (100 / 800).
E3 · Advanced challenge. In the Go pipeline, why must merge close its output in a separate goroutine after wg.Wait() instead of at the end of the merge function?
Answer: merge returns the channel immediately so the caller can start reading; the forwarding goroutines may block on sends until the caller reads. Waiting inside merge before returning would deadlock (nobody is reading yet), and closing immediately would cause sends on a closed channel.
Quiz
? In Go, who should close a channel?
+ The sole sender, once it has nothing more to send :: Receivers cannot know whether other senders exist.
- Any receiver when it is done :: Senders would then panic.
- Every goroutine that used it :: Closing twice panics.
- Nobody; channels close automatically :: They are garbage collected, not closed.
? What does backpressure accomplish?
+ It slows or refuses producers so consumers are not overwhelmed. :: Overload becomes visible and bounded.
- It speeds up consumers. :: It changes producer behavior.
- It guarantees FIFO order. :: Order is a separate property.
- It removes the need for synchronization. :: The queue still synchronises.
? Which are caveats of poison-pill shutdown? (select all that apply)
+ You need one pill per consumer. :: Each pill stops one consumer.
+ Pills wait behind all queued work. :: Shutdown can be slow.
- Pills can be delivered to producers. :: Only consumers take items.
+ A crashed consumer leaves its pill unconsumed. :: Counting pills no longer matches.Summary
Producer-consumer decoupling through bounded queues and channels is the safest concurrency pattern: items change owner instead of being shared. Bounds plus an explicit full policy give backpressure; close/cancellation give clean shutdown; fan-out, fan-in, pipelines, batching and work stealing organise throughput. Test exactly-once delivery and shutdown under stress.
Further Reading
Go blog: "Go Concurrency Patterns: Pipelines and cancellation".
Microsoft Docs: "System.Threading.Channels".
C. A. R. Hoare, *Communicating Sequential Processes* (1985).
Python docs: queue.