Skip to content

← True Parallelism and the Runtime step 13 of 18

Hard End-to-End

Queue Mechanics: The Feeder Thread and the Join Deadlock

This is the number one cause of “my multiprocessing script hangs at the end” — and the reason it survives code review is that it only hangs when the payload exceeds the pipe buffer, roughly 64 KiB. Every small-input test passes.

What a multiprocessing.Queue actually is

Not a queue. It is three things: an OS pipe, a collections.deque buffer, and a background feeder thread started lazily on first put().

put() appends to the deque and returns immediately. The feeder thread then pickles the object and writes the bytes into the pipe. A pipe has a fixed kernel buffer; once it is full, the feeder blocks until a reader drains it.

Now read the documented deadlock:

A process that has put items in a queue will wait before terminating until all the buffered items are fed by the feeder thread to the underlying pipe.

So:

p.start()
p.join()          # <- parent blocks here
result = q.get()  # <- never reached

The child cannot exit until its feeder has written everything. The feeder cannot write because the pipe is full. The pipe is full because the parent is blocked in join() instead of reading. Three parties, no progress, no traceback, no exit code. Always drain the queue before you join.

Everything else about Queue you will need

  • qsize(), empty() and full() are documented as unreliable, and qsize() raises NotImplementedError on macOS. Never build control flow on them.
  • Queue has no task_done() / join(). That is JoinableQueue.
  • SimpleQueue has no feeder thread and no bufferingput() blocks until the write completes. That makes it the right primitive when you want backpressure rather than an unbounded in-memory buffer.
  • A Manager().Queue() is a proxy to a server process and has none of these semantics — different performance, different failure modes.

Your task

Build the shutdown protocol that is correct by construction.

def solve(*, items: list[int], workers: int) -> tuple[tuple[int, ...], int]:

Start workers consumers on a shared inbox. Each consumes ints until it sees the None sentinel, then posts exactly one Done(worker=index) to the outbox and returns. The parent puts all the items, then one sentinel per worker, then drains the outbox — collecting results and counting Done messages — and only joins the threads once it has seen workers of them.

Return the sorted results (each item squared) and the sentinel count.

qsize() must never appear in your solution. Counting sentinels is what replaces it, and it is the technique that works on every platform.

Two cases that catch a half-built protocol: items=[] with workers=4 must still return four sentinels, and workers greater than len(items) must not leave a worker blocked on an empty inbox forever.

The typing lesson

The sentinel’s type is a design decision.

Queue[Item | None]     # every consumer, forever, must handle None
Queue[Item | Done]     # isinstance(msg, Done) narrows to exactly two branches

A Done dataclass carries information — which worker finished — costs nothing, and narrows cleanly. None is the sentinel you reach for when you have not decided what the message means yet, and it leaks that indecision into every consumer downstream.

The other habit worth forming: annotate the queue explicitly. multiprocessing.queues.Queue[Item], never the bare class, and never letting inference hand you Queue[Any] from a ctx.Queue() call.

Loading visualization…