Producer-consumer
Build a bounded blocking queue from scratch, the most asked concurrency coding question. Locks, condition variables, lost wakeups, backpressure and clean shutdown, with demos and code in five languages.
- Read first
- Condition variables, semaphores
An image service accepts uploads on 50 request threads. It resizes each image on 8 worker threads. The request threads shouldn’t wait for resizing. And the workers shouldn’t care where the images came from. Between them sits a queue. Request threads produce jobs into it, and workers consume jobs from it. That’s the producer-consumer pattern. It’s under almost every concurrent system you’ll design: thread pools, logging pipelines, Kafka consumers, the network stack.
The interesting part is the queue. Every thread shares it, so it must be thread safe. A consumer that finds it empty must wait without spinning. And it must have a limit. Say uploads arrive faster than workers can resize. Then an unbounded queue grows until the process runs out of memory. “Implement a bounded blocking queue” is the single most asked concurrency coding question. That’s because building one uses everything in the foundations pages.
The design, in stages
Section titled “The design, in stages”Stage 1: a list and a loop (broken twice)
Section titled “Stage 1: a list and a loop (broken twice)”Here is the first version everyone writes. Producers append to a shared list. Consumers loop until it’s non-empty, and remove the head.
// Two bugs. Don't ship this.while (list.isEmpty()) { } // spins: one core at 100% doing nothingJob job = list.remove(0); // check-then-act: another consumer may have taken itThis has three problems:
- The empty-check and the remove are a check-then-act. So two consumers can both see one item, and both try to remove it.
- The list itself isn’t thread safe, so concurrent appends can corrupt it.
- The loop burns a whole core while waiting. And there’s no guarantee it ever sees another thread’s append (a visibility bug).
Each problem has a tool from the foundations pages: a lock for the first two, and a condition variable for the third.
Stage 2: one lock, two conditions
Section titled “Stage 2: one lock, two conditions”The correct design is small. One lock guards the buffer and its count.
putwaits in a loop while the buffer is full. Then it adds the item, and signalsnotEmpty.takewaits in a loop while the buffer is empty. Then it removes the head, and signalsnotFull.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.concurrent.atomic.AtomicLong;import java.util.concurrent.locks.Condition;import java.util.concurrent.locks.ReentrantLock;
// Three producers put 0..29,999 between them; three consumers take everything. Nothing may be// lost, nothing taken twice, and the buffer must never hold more than its capacity.public class Main { public static void main(String[] args) throws InterruptedException { BoundedBlockingQueue<Integer> queue = new BoundedBlockingQueue<>(4); AtomicLong sum = new AtomicLong(); Thread[] threads = new Thread[6]; for (int p = 0; p < 3; p++) { final int base = p * 10_000; threads[p] = new Thread(() -> { try { for (int i = 0; i < 10_000; i++) queue.put(base + i); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } for (int c = 3; c < 6; c++) { threads[c] = new Thread(() -> { try { for (int i = 0; i < 10_000; i++) sum.addAndGet(queue.take()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } for (Thread t : threads) t.start(); for (Thread t : threads) t.join(); long expected = 29_999L * 30_000 / 2; if (sum.get() != expected || queue.size() != 0) throw new AssertionError("lost or duplicated items"); System.out.println("delivered all 30000 items, sum = " + sum.get()); // sum = 449985000 }}
// A fixed-capacity FIFO queue. put() blocks while it's full; take() blocks while it's empty.// One lock guards all the state; two conditions let producers and consumers wait separately.class BoundedBlockingQueue<T> { private final Object[] items; // ring buffer: no allocation per item private int head, tail, count; // guarded by lock private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); // producers wait here private final Condition notEmpty = lock.newCondition(); // consumers wait here
BoundedBlockingQueue(int capacity) { items = new Object[capacity]; }
void put(T item) throws InterruptedException { lock.lock(); try { // while, not if: a woken producer may find the slot taken by another producer. while (count == items.length) notFull.await(); items[tail] = item; tail = (tail + 1) % items.length; count++; // Something is now available. signal (not signalAll) is enough: only consumers wait // on notEmpty, and any one of them can take this item. notEmpty.signal(); } finally { lock.unlock(); } }
@SuppressWarnings("unchecked") T take() throws InterruptedException { lock.lock(); try { while (count == 0) notEmpty.await(); T item = (T) items[head]; items[head] = null; // don't keep a reference the garbage collector could free head = (head + 1) % items.length; count--; notFull.signal(); // a slot is free: wake one producer return item; } finally { lock.unlock(); } }
int size() { lock.lock(); try { return count; } finally { lock.unlock(); } }}import threadingfrom collections import dequefrom typing import Deque, Generic, TypeVar
T = TypeVar("T")
# A fixed-capacity FIFO queue. put() blocks while full; take() blocks while empty.## One lock guards all the state; two conditions on that same lock let producers and# consumers wait separately, so a wakeup always reaches a thread that can use it.class BoundedBlockingQueue(Generic[T]): def __init__(self, capacity: int) -> None: self._capacity = capacity self._items: Deque[T] = deque() # guarded by _lock self._lock = threading.Lock() self._not_full = threading.Condition(self._lock) # producers wait here self._not_empty = threading.Condition(self._lock) # consumers wait here
def put(self, item: T) -> None: with self._lock: # while, not if: a woken producer may find the slot taken by another producer. while len(self._items) == self._capacity: self._not_full.wait() self._items.append(item) # Only consumers wait on _not_empty, and any one can take this item. self._not_empty.notify()
def take(self) -> T: with self._lock: while not self._items: self._not_empty.wait() item = self._items.popleft() self._not_full.notify() # a slot is free: wake one producer return item
def size(self) -> int: with self._lock: return len(self._items)
if __name__ == "__main__": queue: BoundedBlockingQueue[int] = BoundedBlockingQueue(4) totals: list[int] = [] totals_lock = threading.Lock()
def produce(base: int) -> None: for i in range(5_000): queue.put(base + i)
def consume() -> None: local = sum(queue.take() for _ in range(5_000)) with totals_lock: totals.append(local)
threads = [threading.Thread(target=produce, args=(p * 5_000,)) for p in range(3)] threads += [threading.Thread(target=consume) for _ in range(3)] for t in threads: t.start() for t in threads: t.join() assert sum(totals) == sum(range(15_000)) and queue.size() == 0, "lost or duplicated items" print("delivered all 15000 items, sum =", sum(totals)) # sum = 112492500using System;using System.Threading;
// Three producers put 0..29,999 between them; three consumers take everything. Nothing may be// lost, nothing taken twice, and the buffer must never hold more than its capacity.static class Program{ static void Main() { var queue = new BoundedBlockingQueue<int>(4); long sum = 0; var threads = new Thread[6]; for (int p = 0; p < 3; p++) { int baseValue = p * 10_000; threads[p] = new Thread(() => { for (int i = 0; i < 10_000; i++) queue.Put(baseValue + i); }); } for (int c = 3; c < 6; c++) { threads[c] = new Thread(() => { for (int i = 0; i < 10_000; i++) Interlocked.Add(ref sum, queue.Take()); }); } foreach (var t in threads) t.Start(); foreach (var t in threads) t.Join(); long expected = 29_999L * 30_000 / 2; if (sum != expected || queue.Count != 0) throw new InvalidOperationException("lost or duplicated items"); Console.WriteLine($"delivered all 30000 items, sum = {sum}"); // sum = 449985000 }}
// A fixed-capacity FIFO queue. Put blocks while it's full; Take blocks while it's empty.// C#'s Monitor has one wait set per lock, so producers and consumers share it, and every state// change must PulseAll: Pulse could wake a thread of the wrong kind and lose the wakeup.class BoundedBlockingQueue<T>{ private readonly T[] _items; // ring buffer: no allocation per item private int _head, _tail, _count; // guarded by _gate private readonly object _gate = new();
public BoundedBlockingQueue(int capacity) => _items = new T[capacity];
public void Put(T item) { lock (_gate) { // while, not if: a woken producer may find the slot taken by another producer. while (_count == _items.Length) Monitor.Wait(_gate); _items[_tail] = item; _tail = (_tail + 1) % _items.Length; _count++; Monitor.PulseAll(_gate); // wake consumers (and, harmlessly, waiting producers) } }
public T Take() { lock (_gate) { while (_count == 0) Monitor.Wait(_gate); T item = _items[_head]; _items[_head] = default!; // drop the reference so it can be collected _head = (_head + 1) % _items.Length; _count--; Monitor.PulseAll(_gate); return item; } }
public int Count { get { lock (_gate) return _count; } }}package main
import ( "fmt" "sync" "sync/atomic")
// BoundedBlockingQueue is a fixed-capacity FIFO queue built from a mutex and two condition// variables, to show the mechanism. In real Go code this whole type is `make(chan T, capacity)`.type BoundedBlockingQueue[T any] struct { mu sync.Mutex notFull, notEmpty *sync.Cond // producers wait on notFull, consumers on notEmpty items []T // ring buffer, guarded by mu head, tail, count int // guarded by mu}
func NewBoundedBlockingQueue[T any](capacity int) *BoundedBlockingQueue[T] { q := &BoundedBlockingQueue[T]{items: make([]T, capacity)} q.notFull = sync.NewCond(&q.mu) // both conditions share the one lock q.notEmpty = sync.NewCond(&q.mu) return q}
func (q *BoundedBlockingQueue[T]) Put(item T) { q.mu.Lock() defer q.mu.Unlock() // for, not if: a woken producer may find the slot taken by another producer. for q.count == len(q.items) { q.notFull.Wait() // unlocks mu while asleep, relocks before returning } q.items[q.tail] = item q.tail = (q.tail + 1) % len(q.items) q.count++ q.notEmpty.Signal() // only consumers wait here, and any one can take this item}
func (q *BoundedBlockingQueue[T]) Take() T { q.mu.Lock() defer q.mu.Unlock() for q.count == 0 { q.notEmpty.Wait() } item := q.items[q.head] var zero T q.items[q.head] = zero // drop the reference so it can be collected q.head = (q.head + 1) % len(q.items) q.count-- q.notFull.Signal() // a slot is free: wake one producer return item}
func main() { q := NewBoundedBlockingQueue[int](4) var sum atomic.Int64 var wg sync.WaitGroup for p := range 3 { wg.Add(1) go func() { defer wg.Done() for i := range 10_000 { q.Put(p*10_000 + i) } }() } for range 3 { wg.Add(1) go func() { defer wg.Done() for range 10_000 { sum.Add(int64(q.Take())) } }() } wg.Wait() if sum.Load() != 29_999*30_000/2 { panic("lost or duplicated items") } fmt.Println("delivered all 30000 items, sum =", sum.Load()) // sum = 449985000}// A bounded queue for async code. Nothing blocks a thread in Node, so "block" means "return a// promise that resolves later". Waiting producers and consumers are queues of resolvers, and// each one is woken directly by the operation that makes room or supplies an item.class BoundedQueue<T> { readonly #capacity: number; #items: T[] = []; #waitingTakers: Array<(item: T) => void> = []; #waitingPutters: Array<() => void> = [];
constructor(capacity: number) { this.#capacity = capacity; }
async put(item: T): Promise<void> { // while, not if: another put may fill the slot between our wakeup and our turn to run. while (this.#items.length >= this.#capacity) { await new Promise<void>((resolve) => this.#waitingPutters.push(resolve)); } const taker = this.#waitingTakers.shift(); if (taker) taker(item); // hand the item straight to a waiting consumer else this.#items.push(item); }
async take(): Promise<T> { if (this.#items.length > 0) { const item = this.#items.shift()!; this.#waitingPutters.shift()?.(); // a slot is free: wake one producer return item; } return new Promise<T>((resolve) => this.#waitingTakers.push(resolve)); }
get size(): number { return this.#items.length; }}
async function main(): Promise<void> { const queue = new BoundedQueue<number>(4); let sum = 0; let peak = 0; const produce = async (base: number) => { for (let i = 0; i < 10_000; i++) { await queue.put(base + i); peak = Math.max(peak, queue.size); } }; const consume = async () => { for (let i = 0; i < 10_000; i++) { // Not `sum += await queue.take()`: that reads `sum` before the await and writes it after, // a lost update across an await, the very bug from the race conditions page. const item = await queue.take(); sum += item; } }; await Promise.all([produce(0), produce(10_000), produce(20_000), consume(), consume(), consume()]); if (sum !== (29_999 * 30_000) / 2 || peak > 4) throw new Error("lost items or overfilled"); console.log(`delivered all 30000 items, sum = ${sum}`); // sum = 449985000}
main();The design decisions, in the order an interviewer will probe them:
- A ring buffer instead of a linked list. A ring buffer is a fixed array, with head and tail indexes that wrap around. There’s no allocation per item, and the capacity is the array length. A deque works as well in Python.
- Every wait is a
whileloop. A woken producer may find the slot already taken by another producer that got the lock first. And any thread can wake spuriously. - Signal after the state change, under the lock.
putsignalsnotEmptyonly after the item is in. So the consumer it wakes will find it. signal, notsignalAll, because the conditions are separate. Only consumers wait onnotEmpty, and any one of them can take the new item. So waking one is enough. Waking all of them would be correct, but would wake N threads to hand one item to one of them.- C# can’t separate the conditions with
Monitor. It has one wait set per lock, so it mustPulseAll. That’s correct, and it’s exactly the trade-off the next stage explains. - Go has
sync.Condto show the mechanism. But real Go code is one line:make(chan Job, capacity). - TypeScript keeps queues of waiting resolvers, and hands an item straight to a waiting
consumer. Its test also hit the race from page one.
sum += await queue.take()readssumbefore the await. It lost updates on a single thread, until it was split into two statements.
- Color key
- Facade
- Queue or stream
Implement a bounded blocking queue with put and take. Multiple producers, multiple consumers.
Whether you reach for one lock and two conditions within a few minutes. And whether you get the
details right: while loops, signaling after the state change, the right condition for each side.
The red flags are a busy-wait, an if around wait(), or a single condition with signal().
So is not mentioning the real answer in production: ArrayBlockingQueue, queue.Queue,
Channel<T> or a Go channel.
Stage 3: one condition and the lost wakeup
Section titled “Stage 3: one condition and the lost wakeup”Why not one condition variable for both “not full” and “not empty”? It looks simpler, and with
signalAll it’s correct. But with signal, it can stall forever. Two producers each put one item
into a buffer that holds one. Two consumers each take one. Press Play the lost wakeup:
- Both consumers find the buffer empty and wait on the shared condition.
- Producer A puts an item and signals, waking consumer C. Producer B finds the buffer full, and waits. The wait set is now: consumer D, producer B.
- Consumer C takes the item and signals, meaning to wake a producer. But
signalwakes the first waiter, which is consumer D. - D finds the buffer empty, and goes back to sleep. Producer B was never woken, and it’s the one thread that could make progress. Everyone is asleep, and nobody is left to signal.
- lock(m)
- while (count == capacity)
- changed.wait(m)
- buffer.add(item); count++
- changed.signal()
- unlock(m)
ready
- lock(m)
- while (count == capacity)
- changed.wait(m)
- buffer.add(item); count++
- changed.signal()
- unlock(m)
ready
- lock(m)
- while (count == 0)
- changed.wait(m)
- item = buffer.remove(); count--
- changed.signal()
- unlock(m)
ready
- lock(m)
- while (count == 0)
- changed.wait(m)
- item = buffer.remove(); count--
- changed.signal()
- unlock(m)
ready
Two producers (A, B) put one item each into a buffer that holds one; two consumers (C, D) take one each. Press Play for the order that puts every thread to sleep with one condition variable and signal().
Switch to signalAll(), and play the same order. Everyone wakes. The threads that can’t proceed
re-check and sleep again, and B gets its turn.
Then switch to two conditions. Consumers only ever wake producers, and producers only wake
consumers. So no wakeup is wasted. That’s why ArrayBlockingQueue is built on one lock with two
conditions. It’s also why Java’s built-in notify() is so often a bug.
Stage 4: with semaphores
Section titled “Stage 4: with semaphores”The same queue can be built from two counting semaphores and a mutex. This is the textbook version (Dijkstra’s):
emptystarts at the capacity: free slots.fullstarts at 0: items available.put: acquireempty, lock, add, unlock, releasefull.take: acquirefull, lock, remove, unlock, releaseempty.
The semaphores do the waiting and the counting. The mutex only protects the buffer during the add or remove. It’s correct, and it shows the semaphore’s strength as a counter of resources.
But it’s less flexible than the condition version. A timed put, a size() that’s consistent with
the waits, or a “close” operation are all harder to add. And take the order of operations seriously.
Taking the mutex before empty deadlocks. A producer would sleep on a full buffer while holding
the lock every consumer needs to make space.
Stage 5: use the library, and design the shutdown
Section titled “Stage 5: use the library, and design the shutdown”In production, you never write the queue. Every language ships one, tested for years, with timeouts and draining built in. What you still design is the pipeline around it: how many workers, how big the buffer, and how it stops.
import java.util.concurrent.ArrayBlockingQueue;import java.util.concurrent.BlockingQueue;import java.util.concurrent.atomic.AtomicLong;
// One producer reads 1,000 jobs; three workers process them; then everything shuts down cleanly.public class Main { // A sentinel no real job can equal. Each worker stops when it takes one. static final Integer POISON = Integer.MIN_VALUE;
public static void main(String[] args) throws InterruptedException { // Bounded: when workers fall behind, put() blocks the producer instead of letting the // queue grow until the process runs out of memory. That blocking is backpressure. BlockingQueue<Integer> jobs = new ArrayBlockingQueue<>(8); AtomicLong done = new AtomicLong(); int workers = 3;
Thread[] pool = new Thread[workers]; for (int w = 0; w < workers; w++) { pool[w] = new Thread(() -> { try { while (true) { Integer job = jobs.take(); // blocks while the queue is empty if (job.equals(POISON)) return; // one pill per worker: this one is mine done.addAndGet(job); // "process" the job } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // restore the flag and exit } }); pool[w].start(); }
for (int i = 1; i <= 1_000; i++) jobs.put(i); // blocks while all 8 slots are full // Shutdown: one pill per worker, queued behind the real jobs, so every job is processed // before any worker stops. for (int w = 0; w < workers; w++) jobs.put(POISON); for (Thread t : pool) t.join();
if (done.get() != 500_500) throw new AssertionError("jobs lost: " + done.get()); System.out.println("processed 1000 jobs, sum = " + done.get()); // sum = 500500 }}import queueimport threading
POISON = object() # a sentinel no real job can equal; each worker stops when it takes one
def worker(jobs: "queue.Queue[object]", results: list[int], lock: threading.Lock) -> None: total = 0 while True: job = jobs.get() # blocks while the queue is empty if job is POISON: break # one pill per worker: this one is mine total += job # type: ignore[operator] # "process" the job with lock: results.append(total) # each worker reports once, at the end
if __name__ == "__main__": # Bounded: when workers fall behind, put() blocks the producer instead of letting the queue # grow without limit. That blocking is backpressure. jobs: "queue.Queue[object]" = queue.Queue(maxsize=8) results: list[int] = [] lock = threading.Lock() pool = [threading.Thread(target=worker, args=(jobs, results, lock)) for _ in range(3)] for t in pool: t.start()
for i in range(1, 1_001): jobs.put(i) # blocks while all 8 slots are full # Shutdown: one pill per worker, behind the real jobs, so every job is processed before any # worker stops. Python 3.13 adds Queue.shutdown(), which does this without sentinels. for _ in pool: jobs.put(POISON) for t in pool: t.join()
assert sum(results) == 500_500, sum(results) print("processed 1000 jobs, sum =", sum(results)) # sum = 500500using System;using System.Linq;using System.Threading;using System.Threading.Channels;using System.Threading.Tasks;
// One producer writes 1,000 jobs; three workers process them; then everything shuts down cleanly.static class Program{ static async Task Main() { // Bounded with FullMode.Wait: when workers fall behind, WriteAsync waits instead of // letting the channel grow without limit. That waiting is backpressure. Waiting here is // asynchronous, so no thread is held while the producer is paused. var jobs = Channel.CreateBounded<int>(new BoundedChannelOptions(8) { FullMode = BoundedChannelFullMode.Wait, }); long done = 0;
var workers = Enumerable.Range(0, 3).Select(_ => Task.Run(async () => { // ReadAllAsync ends when the channel is completed and drained: no poison pills needed. await foreach (var job in jobs.Reader.ReadAllAsync()) Interlocked.Add(ref done, job); // "process" the job })).ToArray();
for (int i = 1; i <= 1_000; i++) await jobs.Writer.WriteAsync(i); jobs.Writer.Complete(); // shutdown: no more writes; readers finish what's queued, then stop await Task.WhenAll(workers);
if (done != 500_500) throw new InvalidOperationException($"jobs lost: {done}"); Console.WriteLine($"processed 1000 jobs, sum = {done}"); // sum = 500500 }}package main
import ( "fmt" "sync" "sync/atomic")
// One producer sends 1,000 jobs; three workers process them; then everything shuts down cleanly.func main() { // A buffered channel is Go's bounded blocking queue. When workers fall behind, the send // blocks the producer: backpressure, built in. jobs := make(chan int, 8) var done atomic.Int64 var wg sync.WaitGroup
for range 3 { wg.Add(1) go func() { defer wg.Done() // range ends when the channel is closed and drained: every job sent is received. for job := range jobs { done.Add(int64(job)) // "process" the job } }() }
for i := 1; i <= 1_000; i++ { jobs <- i // blocks while all 8 slots are full } // Shutdown: only the sender closes, and only once. Sending on a closed channel panics, so // with several producers, close after a WaitGroup says they've all finished. close(jobs) wg.Wait()
if done.Load() != 500_500 { panic(fmt.Sprint("jobs lost: ", done.Load())) } fmt.Println("processed 1000 jobs, sum =", done.Load()) // sum = 500500}// One producer writes 1,000 jobs; three async workers process them; then everything shuts down// cleanly. A small Channel like C#'s and Go's: bounded, closable, and readable with for await.class Channel<T> { readonly #capacity: number; #items: T[] = []; #closed = false; #readers: Array<(r: IteratorResult<T>) => void> = []; #writers: Array<() => void> = [];
constructor(capacity: number) { this.#capacity = capacity; }
async write(item: T): Promise<void> { if (this.#closed) throw new Error("write after close"); // Backpressure: a full channel makes the producer wait instead of growing without limit. while (this.#items.length >= this.#capacity) await new Promise<void>((r) => this.#writers.push(r)); const reader = this.#readers.shift(); if (reader) reader({ value: item, done: false }); else this.#items.push(item); }
// No more writes. Readers finish what's queued, then their loops end. close(): void { this.#closed = true; for (const reader of this.#readers.splice(0)) reader({ value: undefined, done: true }); }
async *[Symbol.asyncIterator](): AsyncGenerator<T> { for (;;) { if (this.#items.length > 0) { const item = this.#items.shift()!; this.#writers.shift()?.(); // a slot is free: wake one waiting writer yield item; } else if (this.#closed) { return; } else { const next = await new Promise<IteratorResult<T>>((r) => this.#readers.push(r)); if (next.done) return; yield next.value; } } }}
async function main(): Promise<void> { const jobs = new Channel<number>(8); let done = 0; const worker = async () => { for await (const job of jobs) { await new Promise((r) => setImmediate(r)); // "process" the job (some I/O) done += job; // no await between reading and writing `done`, so no lost update } }; const workers = [worker(), worker(), worker()]; for (let i = 1; i <= 1_000; i++) await jobs.write(i); jobs.close(); await Promise.all(workers); if (done !== 500_500) throw new Error(`jobs lost: ${done}`); console.log(`processed 1000 jobs, sum = ${done}`); // sum = 500500}
main();Shutdown is the part people forget, and each language has its own idiom:
- Poison pills (Java, Python): after the last real job, the producer puts one special sentinel per consumer. A consumer that takes one exits. Pills queue behind the real jobs, so every job is processed before any worker stops. With several producers, someone must know when all of them are done, before sending the pills.
- Closing the queue (C#
Writer.Complete(), Goclose(ch), Python 3.13’sQueue.shutdown()): readers drain what’s left, and their loops end. There are no sentinels to count. In Go, only the sender may close, exactly once, because sending on a closed channel panics. - Interrupting or cancelling (Java
interrupt(), aCancellationToken, a Gocontext): this stops workers now, and abandons queued jobs. Use it for a fast shutdown. Then decide what happens to the abandoned jobs: requeue them, or rely on the source to redeliver them.
I’ll guard the buffer with one lock, and use two conditions. Producers wait on notFull while it’s full. Consumers wait on notEmpty while it’s empty. Both wait in while loops. After a put I signal notEmpty, and after a take I signal notFull. In production, I’d use the library’s blocking queue, and spend the time on capacity and shutdown.
Backpressure: what a full queue means
Section titled “Backpressure: what a full queue means”When consumers are slower than producers, the queue fills. What happens next is a design decision, not a detail. Interviewers love it:
| Policy when full | Producer sees | Good for |
|---|---|---|
| Block | put waits until there’s room |
Batch pipelines, where slowing the source is the goal |
| Block with timeout | offer(item, 100ms) returns false |
Request handling: fail the request instead of hanging it |
| Reject | offer returns false at once, or an exception |
Load shedding: return 429 or 503 and let the client retry |
| Drop oldest | Succeeds; the oldest queued item is discarded | Telemetry, live prices: only the latest values matter |
| Unbounded | Always succeeds | Almost never: it just moves the failure to an out-of-memory |
How big should the queue be? Size it for bursts, not for sustained overload. Say workers handle 400 jobs a second, and traffic bursts at 600 a second for up to 5 seconds. Then the queue needs about (600 - 400) × 5 = 1,000 slots to absorb the burst.
If producers stay faster than consumers for longer than that, no buffer is big enough. You need more consumers, or less work. A deep queue mostly adds latency. With 10,000 jobs queued at 400 a second, a new job waits 25 seconds.
Each language, honestly
Section titled “Each language, honestly”| Language | The queue to use | Shutdown | Notes |
|---|---|---|---|
| Java | ArrayBlockingQueue (bounded, one lock), LinkedBlockingQueue (two locks, unbounded by default) |
Poison pills, or interrupt() |
Always pass a capacity to LinkedBlockingQueue |
| Python | queue.Queue(maxsize=n); asyncio.Queue for coroutines |
Sentinels; Queue.shutdown() from 3.13 |
multiprocessing.Queue between processes, for CPU-bound work |
| C# | Channel.CreateBounded<T>; BlockingCollection<T> (older, blocks threads) |
Writer.Complete() |
Channels are async: a waiting reader holds no thread |
| Go | make(chan T, n) |
close(ch) by the sender; range drains it |
select with a default case is a non-blocking offer |
| TypeScript | Nothing built in; a small class like the one above, or a stream | A close() that ends the readers’ loops |
Node streams have backpressure built in (highWaterMark) |
Interview questions
Section titled “Interview questions”Interviewer asksWhy use while instead of if around wait() in take()?
Because the queue can be empty again when the consumer gets the lock back. Another consumer may
have taken the item between the signal and this consumer retaking the lock. Or the wakeup may have
been spurious. The while re-checks after every wakeup, and goes back to sleep if needed.
Interviewer asksAdd put with a timeout. What changes?
offer(item, timeout) computes a deadline once, then loops. While the queue is full, it computes
the time left. If that’s zero or less, it returns false. Otherwise, it waits on notFull for at
most that long. Computing from one deadline matters. Otherwise each wakeup (spurious or stolen)
would restart the full timeout. poll(timeout) on the consumer side is the mirror image.
Interviewer asksHow would you shut down the consumers without losing jobs?
There are two ways. Put one poison pill per consumer after the last real job. The pills queue
behind the work, and each consumer exits after taking one. Or close the queue (Go close, C#
Complete, Python shutdown), so readers drain the remaining items and then stop. For a fast
shutdown, interrupt or cancel the consumers. Then either requeue the jobs they abandon, or make
sure the source redelivers them.
Interviewer asksLinkedBlockingQueue uses two locks. Why, and why doesn't ArrayBlockingQueue?
A linked queue’s producers only touch the tail, and its consumers only touch the head. So a
separate put lock and take lock let one producer and one consumer work at the same time. The count
is an atomic shared by both sides. An array ring buffer could do the same. But
ArrayBlockingQueue keeps one lock, because it’s simpler and the critical sections are tiny. Two
locks help when there’s real contention between the two sides.
Interviewer asksConsumers are slower than producers for an hour. What happens with each queue choice?
With an unbounded queue, memory grows until the process dies. Before that, every job’s latency grows with the backlog. With a bounded blocking queue, producers block. If producers are request threads, request latency rises, and the server stops accepting work. That’s the right signal to the load balancer. With a reject policy, extra requests fail fast with 429 or 503. In all cases, the real fix is more consumer capacity or less work per job. The queue only chooses how the overload shows up.
Go deeperFaster than a lock: the LMAX Disruptor2 min
Locked queues cost a few hundred nanoseconds per item under contention. Most of that comes from waking sleeping threads, and from the lock’s cache line bouncing between cores. The LMAX Disruptor was built for a trading exchange. It replaces the queue with a pre-allocated ring buffer and sequence counters:
- A producer claims a slot with a CAS on the sequence.
- It writes into the slot.
- It publishes by advancing a counter.
- Consumers spin or yield on that counter, instead of sleeping.
It moves millions of items a second per core, with microsecond latency.
The cost: busy-waiting consumers burn CPU, and the design is far harder to get right. And the gain only shows up at rates most services never reach. Mention it in a senior interview as the answer to “what if the queue itself is the bottleneck?” And say you’d measure first.
| Decision | Choice | Why |
|---|---|---|
| Guarding state | One lock over buffer and count | The invariant spans both |
| Waiting | notFull and notEmpty conditions, while loops |
No spinning; stolen and spurious wakeups are harmless |
| Waking | signal the other side’s condition |
Every wakeup reaches a thread that can use it |
| One condition? | Only with signalAll |
signal can wake the wrong kind and stall everyone |
| Bounded | Fixed capacity, ring buffer | Backpressure, a memory ceiling and bounded latency |
| Full-queue policy | Block, time out, reject or drop, per use case | Decides how overload shows up |
| Shutdown | Poison pills or close, then drain | No job lost, no worker left waiting forever |
| Production | The library queue or channel | Same design, already tested |
Further reading
Section titled “Further reading”- The source of
java.util.concurrent.ArrayBlockingQueue. The logic is about 100 lines, and uses the same one lock with two conditions shown here. - Martin Thompson et al., The LMAX Disruptor.
- Go blog: Pipelines and cancellation.
- Next: Thread pools and async, the producer-consumer queue with a fixed set of workers attached.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.