A queue is a lock, a condition and a deque already assembled correctly - which is why it is the one piece of threading plumbing you should reach for first.
Overview
Why a queue rather than a lock
The previous pages built up a set of primitives and a set of ways to misuse them: a lock you must take on every access, a Condition whose wait must sit in a while loop, a race waiting in any check-then-act. A queue.Queue is those three things already put together by someone who got the details right.
It is internally locked, so put and get are safe from any number of threads with no locking of your own. It blocks when it has nothing to give and — if you bound it — when it has nowhere to put something. And it turns shared mutable state into message passing, which is the design that removes races rather than guarding them: an item is owned by the producer, then by the queue, then by exactly one consumer, and never by two things at once.
The rule that follows is worth adopting as a default. If threads need to coordinate, try to express it as work items flowing through a queue before reaching for a lock around shared state. The queue version is usually shorter and it is much harder to get subtly wrong.
These editors run in a browser interpreter with no OS threads, so the full API below is real and exercisable from one thread, and the multi-threaded worker pool is shown with its CPython output.
Queue depth, and where put() blocks
This explorer needs JavaScript: every
interleaving, cost and speed-up on it is computed in the page
rather than downloaded as an image.
Handing Work Between Threads with a Queue
A queue is a lock, a condition and a deque already assembled correctly - which is why it is the one piece of threading plumbing you should reach for first.
The whole API, from one thread
example_01.pyPython
import queue
q = queue.Queue(maxsize=2)
print("empty?", q.empty(), "| qsize", q.qsize(), "| full?", q.full())
q.put("a"); q.put("b")
print("after two puts: qsize", q.qsize(), "| full?", q.full())
try:
q.put_nowait("c")
except queue.Full:
print("put_nowait on a full queue -> queue.Full "
"(a plain put() would have blocked here)")
print("unfinished_tasks:", q.unfinished_tasks)
print("get ->", q.get()); q.task_done()
print("get ->", q.get()); q.task_done()
print("unfinished_tasks:", q.unfinished_tasks)
q.join()
print("join() returned immediately: every item was marked done")
try:
q.task_done()
except ValueError as e:
print("one task_done too many -> ValueError:", e)
try:
q.get_nowait()
except queue.Empty:
print("get_nowait on an empty queue -> queue.Empty "
"(a plain get() would have blocked forever)")
Output
empty? True | qsize 0 | full? False
after two puts: qsize 2 | full? True
put_nowait on a full queue -> queue.Full (a plain put() would have blocked here)
unfinished_tasks: 2
get -> a
get -> b
unfinished_tasks: 0
join() returned immediately: every item was marked done
one task_done too many -> ValueError: task_done() called too many times
get_nowait on an empty queue -> queue.Empty (a plain get() would have blocked forever)
Everything the queue does is in that output. The two pairs are the part to internalise.
put/getblock; put_nowait/ get_nowaitraise. Blocking is what you want almost always, because it is how the queue paces the two sides. Raising is for the cases where waiting is the wrong behaviour — shedding load deliberately, or draining a queue you know is finished.
task_done/join is a counter, not a queue length. unfinished_tasks went up on each put and down on each task_done, and join returns when it reaches zero. That is a different question from "is the queue empty": an item that has been taken but not yet finished is out of the queue and still unfinished, which is exactly the state you need to wait for at shutdown.
A worker pool that stops cleanly
Here is the whole pattern on CPython. Three workers, a bounded queue, and a shutdown that does not guess:
import queue, threading
def worker(name, q, results):
while True:
item = q.get()
try:
if item is None: # the sentinel: one per worker
return
results.append((name, item * item))
finally:
q.task_done()
q = queue.Queue(maxsize=4)
results = []
workers = [threading.Thread(target=worker, args=(f"w{i}", q, results),
daemon=True)
for i in range(3)]
for t in workers:
t.start()
for n in range(9):
q.put(n)
q.join() # every item handled
for _ in workers: # now tell each worker to stop
q.put(None)
for t in workers:
t.join()
print("items handled:", len(results))
print("squares:", sorted(v for _, v in results))
task_done is in a finally. If the work raises and the call is skipped, unfinished_tasks never reaches zero and join waits forever — a hang at shutdown with no error, which is the most common way this pattern fails.
join comes before the sentinels. It proves the work is finished before anything is asked to stop, so there is no window where a worker exits with items still queued.
There is one sentinel per worker, because each None is consumed by exactly one get. Putting a single sentinel stops one worker and leaves the other two blocked forever — a classic, and the reason many people prefer a threading.Event flag that every worker can see, or simply daemon threads and no shutdown at all.
daemon=True means the interpreter will not wait for these threads at exit. It is a safety net rather than the plan; a daemon thread killed at interpreter exit does not run finally blocks, so anything holding a file or a transaction should be shut down properly, not abandoned.
Bounded, and why it matters
maxsize is the difference between a queue that paces your program and a queue that hides a leak.
Unbounded, put never blocks. A producer faster than its consumers simply accumulates: the queue grows, memory grows, and the program dies of an allocation failure a long way from the cause. The symptom looks like a leak and is a missing bound.
Bounded, a full put blocks until a consumer takes something out, so the producer is automatically slowed to the rate the consumers can sustain. That is backpressure, it costs one argument, and the explorer above this article is the depth of a bounded queue over time under producer and consumer rates you choose — including the flat stretch where the producer is waiting.
The value to pick is a statement about how far ahead you are willing to let the producer run, in items. It is a contract rather than a tuning number: a bound of 100 means "up to 100 items may be buffered", and the right figure follows from how large an item is and how stale a buffered one may become.
The variants, and the async cousin
queue.LifoQueue returns the most recent item first, which turns a breadth-first crawl into a depth-first one with no other change. queue.PriorityQueue returns the smallest first and expects comparable items, so the shape is q.put((priority, payload)) — and a tuple whose second element is not comparable will raise when two priorities tie, which is why a monotonic counter is usually wedged in between.
queue.SimpleQueue is worth knowing for one property: it is unbounded, has no task_done, and is reentrant, so it is the only one safe to use from a signal handler or a __del__.
And the deliberate near-duplicate: asyncio.Queue has almost the same interface and is not thread-safe, while queue.Queue is thread-safe and blocks the thread rather than awaiting. Using the wrong one is a real bug in both directions — a queue.Queue.get() inside a coroutine freezes the event loop, which is the blocking call problem, and an asyncio.Queue touched from two threads corrupts quietly. To bridge the two, use loop.call_soon_threadsafe, asyncio.run_coroutine_threadsafe, or the janus library, which exists precisely for this.
Getting results back out
A queue is one-way, which is the question the worked example dodged by appending to a shared list. That works because list.append is one C call and cannot be interleaved — but it loses the association between an item and its result, and it does not let a caller wait for *its* answer.
Three patterns, in increasing order of how much they give you.
A second queue. Workers put onto a results queue and the main thread drains it. Simple, and the ordering is completion order, so anything that needs to match a result to its request has to carry an identifier in the message. This is the shape most pipelines end up with.
A future in the item. Put (work, future) pairs on the queue, and have the worker call future.set_result(...). Now any caller can wait on exactly its own answer, with a timeout, and exceptions propagate properly through set_exception. This is essentially what ThreadPoolExecutor does internally, which is a good argument for using it instead when the work is a set of independent calls.
A queue per requester. Heavier, and the right answer when a requester needs a stream of replies rather than one, or when replies must stay ordered per-requester.
The rule that falls out: a queue is the right tool when work flows — an open-ended stream, consumed by a fixed number of workers, where the producer should be paced. When you have a known list of independent calls and want their results, an executor is less code and already solves the result and exception plumbing. Reaching for a queue there means reimplementing submit.
Where it goes wrong
An unbounded queue with a fast producer. It grows until memory runs out. Set maxsize.
task_done not in a finally. One exception in a worker and join hangs forever with nothing logged.
One sentinel for several workers. Each is consumed once, so the rest stay blocked on get.
Branching on qsize(). It is a snapshot that another thread can invalidate before the next line. Let put and get do the waiting; the count is for logging, not control.
Using queue.Queue in a coroutine. Its get blocks the thread, so it stops the event loop. Use asyncio.Queue there.
Questions people ask
Sentinels or an Event? An Event scales better: one set() is seen by every worker, where sentinels need one per worker and exact bookkeeping. Sentinels remain useful when workers must finish the queued work first, because the sentinel is ordered behind it and a flag is not.
How many workers? For waiting-bound work, as many as the far end tolerates; for computing-bound work, threads will not help at all and the answer is processes. The queue's value is that this becomes one number in one place.
Do I need join if I use sentinels? Not strictly, but the two answer different questions. join tells you the work is done; joining the threads tells you the workers have exited. Doing both, in that order, is what makes shutdown deterministic.
Is get fair between workers? Roughly, and it is not guaranteed. Items go to whichever worker the OS wakes, so a run where one worker takes more than its share is normal and not a bug.
Can I put anything in it? Any object — it is the same process, so nothing is pickled and nothing is copied. That is a real advantage over a process pool, and it is also why the item is still shared mutable state: two threads holding the same object still need a lock if they both mutate it.
What if a worker dies? The item is lost and unfinished_tasks never decrements, so join hangs. Wrapping the body in try/finally with task_done in the finally is what keeps a crashing worker from hanging the shutdown.
Recap in one screen
A queue is a lock, a condition and a deque already assembled correctly, so it is the first thing to reach for rather than the last.
It converts shared mutable state into message passing: an item has exactly one owner at a time, which removes races instead of guarding them.
put/get block and pace the program; put_nowait/get_nowait raise Full/Empty for when waiting is wrong.
task_done/join is a counter of outstanding items, not a queue length — an item taken but unfinished is neither in the queue nor done.
Put task_done in a finally, call join before sending sentinels, and send one sentinel per worker.
maxsize is backpressure and the difference between pacing a producer and hiding a leak.
queue.Queue is thread-safe and blocking; asyncio.Queue is neither. They are not interchangeable in either direction.
Recall check
0 of 3
Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.
What does this module say about “Why a queue rather than a lock”?
The previous pages built up a set of primitives and a set of ways to misuse them: a lock you must take on every access, a Condition whose wait must sit in a while loop, a race waiting in any check-then-act. A queue.Queue is those three things already put together by someone who got the details right.
What does this module say about “A worker pool that stops cleanly”?
Here is the whole pattern on CPython. Three workers, a bounded queue, and a shutdown that does not guess:
What does this module say about “Bounded, and why it matters”?
maxsize is the difference between a queue that paces your program and a queue that hides a leak.
Cheat sheet
Handing Work Between Threads with a Queue
A queue is a lock, a condition and a deque already assembled correctly - which is why it is the one piece of threading plumbing you should reach for first.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.