Design a Thread-Safe Queue
Problem Design a queue data structure that multiple threads can use concurrently — producers enqueuing and consumers dequeuing — without race conditions, lost elements, or corrupted internal state.
Requirements
enqueue(item) -> bool— add to the taildequeue() -> Optional<T>— remove from the headsize() -> intandisEmpty() -> bool- Blocking variants:
put(item)blocks while full,take()blocks while empty - Safe under many concurrent producers and consumers; no element lost or returned twice
Core design
- Back the queue with a ring buffer (fixed capacity, head/tail indices) or a linked list (unbounded, node per element). Bounded is usually the right default — an unbounded queue turns a slow consumer into an out-of-memory crash instead of applying backpressure.
- Lock-based: guard head, tail, and size with a mutex. Hold it only for the pointer/index update — never across the caller's processing, or the queue serializes the whole pipeline. Blocking semantics come from condition variables:
notFullandnotEmpty, with consumers waiting onnotEmptyand producers signalling it after an enqueue. - Condition waits must sit in a
whileloop, not anif— spurious wakeups are real, and with multiple waiters another thread may consume the item between the signal and the wake. - Lock-free: a ring buffer with atomic head/tail indices updated by compare-and-swap. Higher throughput under contention and no risk of a thread being descheduled while holding a lock, at the cost of far subtler correctness.
- Two-lock optimization: a linked queue can hold separate head and tail locks, letting one producer and one consumer proceed simultaneously since they touch opposite ends — only the empty-queue case makes them contend.
size()is inherently approximate under concurrency: the value is stale the moment it returns. Callers must not branch on it (if (!isEmpty()) dequeue()is a race) — the API should make the check-then-act atomic instead.
Discussion points
- Blocking vs. non-blocking semantics: returning
Optional.empty()on an empty queue pushes the retry decision to the caller and invites spin loops; blockingtake()parks the thread but needs an interrupt/timeout path or shutdown deadlocks. - Trade-off: mutex is simple and obviously correct but contended; lock-free scales but exposes the ABA problem and memory-reclamation hazards (freeing a node another thread still holds a pointer to).
- Memory ordering matters — publishing an element requires a release/acquire pair, or a consumer can see the index update before the element write lands.
- Edge cases: shutdown while consumers are blocked (poison pill vs. explicit close), and full-queue policy (block, drop-newest, drop-oldest, or throw).
- Fairness: neither a mutex nor a CAS loop guarantees FIFO among threads, so a producer can starve under load even though elements stay FIFO.
- Extension: a
drainTo(collection)bulk operation amortizes lock acquisition across many elements, often a bigger win than going lock-free.
asked …