Question: Thread-safe task queue on a linked list
Companies: Anthropic
Difficulty: Medium
Problem statement
A task queue was written for one thread: a singly linked list with head and tail, where put appends at the tail and get removes from the head. Explain what goes wrong when many producer and consumer threads share it, then make it safe:
-
put(value, timeout=None)blocks while the queue is atcapacity(if one is set) and raisesFullon timeout. -
get(timeout=None)blocks while the queue is empty and raisesEmptyon timeout. -
close()makes laterputcalls raiseClosed; consumers drain what is left, thengetraisesClosed. Waiting threads must wake up. - Every task is delivered exactly once; one producer's tasks come out in the order it put them.
class Empty(Exception): ...
class Full(Exception): ...
class Closed(Exception): ...
class TaskQueue:
def __init__(self, capacity=None): ...
def put(self, value, timeout=None): ...
def get(self, timeout=None): ...
def close(self): ...
def __len__(self): ...
Approach
The starting point. A task queue written for one thread: a singly linked list with head and tail, put appends at the tail, get removes from the head. Under several producer and consumer threads it breaks in specific ways; naming them is the first part of the answer.
What goes wrong without synchronization.
-
Lost tasks. Two producers both read the same
tail, both settail.next; one node is overwritten and its task vanishes. -
Double delivery. Two consumers both read the same
headand both return its task. -
Broken empty/one-element transitions.
geton the last node must cleartailtoo; a producer appending at that moment can attach its node to a node that is no longer in the list. -
Busy waiting. A consumer on an empty queue spins on
while head is None, burning a core.
The GIL does not save you: it makes single bytecodes atomic, not a read-modify-write of tail spread over several bytecodes.
Design.
-
One lock, two conditions (
not_empty,not_full) on that lock.putwaits while full, links the node, notifiesnot_empty.getwaits while empty, unlinks, notifiesnot_full. Waits are inwhileloops because a thread can wake with nothing to do (another consumer got there first, or a spurious wakeup). -
Timeouts:
get(timeout)raisesEmptyif nothing arrives in time; compute a deadline once and wait for the remaining time, so repeated wakeups do not extend the total wait. -
Shutdown:
close()makesputfail and lets consumers drain what is left, thengetraisesClosed.notify_allon close, or waiting consumers sleep forever. -
A dummy head node removes the empty-list special case:
headalways points at a sentinel; the first real task ishead.next. Thenputtouches onlytailandgettouches onlyhead, which is what makes the two-lock version possible (follow-up 1).
Invariants (checked in the tests): every task put is returned by exactly one get; a single producer's tasks come out in order; size equals the number of nodes after the sentinel.
Code
import threading
import time
class Empty(Exception):
pass
class Full(Exception):
pass
class Closed(Exception):
pass
class _Node:
__slots__ = ("value", "next")
def __init__(self, value=None):
self.value, self.next = value, None
class TaskQueue:
"""Blocking FIFO queue on a singly linked list with a sentinel head."""
def __init__(self, capacity=None):
self.head = self.tail = _Node() # sentinel
self.size, self.capacity, self.closed = 0, capacity, False
self.lock = threading.Lock()
self.not_empty = threading.Condition(self.lock)
self.not_full = threading.Condition(self.lock)
@staticmethod
def _remaining(deadline):
return None if deadline is None else deadline - time.monotonic()
def put(self, value, timeout=None):
deadline = None if timeout is None else time.monotonic() + timeout
with self.not_full:
while not self.closed and self.capacity is not None and self.size >= self.capacity:
left = self._remaining(deadline)
if left is not None and left <= 0:
raise Full
self.not_full.wait(left)
if self.closed:
raise Closed
node = _Node(value)
self.tail.next = node
self.tail = node
self.size += 1
self.not_empty.notify()
def get(self, timeout=None):
deadline = None if timeout is None else time.monotonic() + timeout
with self.not_empty:
while self.size == 0:
if self.closed:
raise Closed
left = self._remaining(deadline)
if left is not None and left <= 0:
raise Empty
self.not_empty.wait(left)
first = self.head.next
self.head = first # first becomes the new sentinel
value, first.value = first.value, None
self.size -= 1
self.not_full.notify()
return value
def close(self):
with self.lock:
self.closed = True
self.not_empty.notify_all()
self.not_full.notify_all()
def __len__(self):
with self.lock:
return self.size
get makes the removed node the new sentinel instead of unlinking it: one pointer write, and the tail never needs fixing when the queue empties.
Complexity
-
put,get: O(1) plus waiting.notify(notnotify_all) on each operation wakes one waiter, avoiding a thundering herd. - Throughput: one lock serializes all operations; with very short critical sections it handles millions of operations per second in C, far fewer in Python (a few hundred thousand per second). Contention, not the list, is the limit.
- Memory: one node per queued task.
Test cases
import threading
import time
from solution import Closed, Empty, Full, TaskQueue
def test_fifo_single_thread():
q = TaskQueue()
for i in range(5):
q.put(i)
assert [q.get() for _ in range(5)] == [0, 1, 2, 3, 4]
assert len(q) == 0
def test_get_times_out_on_empty():
q = TaskQueue()
start = time.monotonic()
try:
q.get(timeout=0.05)
except Empty:
pass
else:
assert False
assert time.monotonic() - start >= 0.04
def test_many_producers_and_consumers_deliver_each_task_once():
q = TaskQueue(capacity=50)
producers, consumers, per = 4, 4, 2000
got, got_lock = [], threading.Lock()
def produce(p):
for i in range(per):
q.put((p, i))
def consume():
for _ in range(producers * per + 1): # bounded, so a broken queue cannot hang the test
try:
item = q.get(timeout=5)
except (Closed, Empty):
return
with got_lock:
got.append(item)
cs = [threading.Thread(target=consume, daemon=True) for _ in range(consumers)]
ps = [threading.Thread(target=produce, args=(p,), daemon=True) for p in range(producers)]
for t in cs + ps:
t.start()
for t in ps:
t.join(10)
q.close()
for t in cs:
t.join(5)
assert sorted(got) == sorted((p, i) for p in range(producers) for i in range(per))
for p in range(producers): # every task of every producer arrived
mine = [i for (pp, i) in got if pp == p]
assert len(mine) == per
def test_bounded_put_blocks_until_space_or_times_out():
q = TaskQueue(capacity=1)
q.put("a")
try:
q.put("b", timeout=0.02)
except Full:
pass
else:
assert False
threading.Timer(0.05, q.get).start()
q.put("b", timeout=1) # succeeds once the timer frees a slot
assert q.get() == "b"
def test_close_wakes_waiters_and_drains():
q = TaskQueue()
q.put("last")
q.close()
assert q.get() == "last"
try:
q.get()
except Closed:
pass
else:
assert False
try:
q.put("x")
except Closed:
pass
else:
assert False
q2 = TaskQueue()
outcome = []
def wait():
try:
q2.get()
except Closed:
outcome.append("closed")
t = threading.Thread(target=wait)
t.start()
time.sleep(0.02)
q2.close()
t.join(1)
assert outcome == ["closed"]
Follow-ups
-
Two locks. Because the sentinel separates the ends, producers only touch
tailand consumers only touchhead. A head lock and a tail lock let one producer and one consumer run at the same time. The sharedsize(for capacity) then needs an atomic counter or its own small lock, and the not-empty signal must be sent under the head lock. This roughly doubles throughput under mixed load in languages without a global lock. -
Lock-free. The Michael–Scott queue uses compare-and-swap on
tail.nextandtail, with helping (a thread that sees a laggingtailadvances it). It needs safe memory reclamation (hazard pointers or epochs) in languages without garbage collection. Not available in pure Python; mention it, do not write it. -
Batching.
get_many(max_items)takes up to n tasks under one lock acquisition, cutting lock traffic for small tasks. -
Fairness and priority. Python's
Conditiondoes not promise FIFO wakeup order among waiters. For strict fairness keep a queue of per-waiter events; for priorities, replace the list with a heap and keep the same locking. - Processes, not threads. Across processes, use a pipe- or socket-backed queue, or a broker; the semantics to preserve are the same: at-most-once removal, blocking with timeouts, and clean shutdown.
Interviewer expectations
Mid-level (L4)
- Explains the races (lost append, double removal) and fixes them with a lock around both operations.
- Replaces busy waiting with a condition variable, waiting in a loop.
- Tests with several producer and consumer threads.
Senior (L5)
- Adds capacity with two conditions, timeouts with a deadline, and a clean close that wakes waiters.
- Uses a sentinel node and explains how it removes the empty-list special case.
- States the invariants and checks them in tests (each task once, per-producer order).
Staff (L6+)
- Describes the two-lock queue and why the sentinel enables it, and the lock-free variant with its memory-reclamation problem.
- Discusses fairness, batching and contention as the real throughput limits.
- Relates the design to process-level and distributed queues.