Stop sharing variables and passing locks around. Hand work between threads through a queue instead.
list, you're back to manual locks,
list.pop() on an empty list, busy-wait loops, and a mess of edge cases at shutdown.
The standard-library answer is queue.Queue. It is a FIFO queue with all locking
built in โ every put() and get() is atomic and thread-safe. You
never touch a Lock yourself. This is the single most useful concurrency primitive in
Python.
One side puts items in, the other side takes them out. The queue sits in the middle as a thread-safe buffer, decoupling the rate of production from the rate of consumption.
q.put(item) โ add work to the tail.q.get() โ pull work from the head.put() and get() Block by DefaultThis blocking behaviour is the whole point โ it's what lets consumers wait for work without a busy-loop, and it gives you backpressure for free.
q.get() on an empty queue blocks until an item is available.q.put() on a full queue (see maxsize) blocks until space frees up.timeout= and raise queue.Empty / queue.Full if it elapses.get(block=False) (a.k.a. get_nowait()) never waits โ it raises queue.Empty immediately if empty.import queue
q = queue.Queue()
# Consumer side โ blocks here until a producer puts something
item = q.get() # waits patiently, no CPU spin
# Non-blocking variants
try:
item = q.get_nowait() # or q.get(block=False)
except queue.Empty:
... # nothing available right now
# Blocking with a deadline
try:
item = q.get(timeout=2.0) # wait up to 2s, then give up
except queue.Empty:
...
maxsize: Backpressure for FreeQueue(maxsize=N) to cap how many items can wait in the
buffer. Once it's full, producers block on put() until consumers catch up.
An unbounded queue is a memory-leak waiting to happen: if producers outrun consumers, the queue grows without limit until you run out of RAM. A bounded queue self-regulates โ a fast producer is naturally throttled to the speed of the consumers.
q = queue.Queue(maxsize=100) # at most 100 items buffered
# If the queue is full, this line simply waits โ producer slows down
q.put(item)
maxsize unless you can prove
consumers keep up. Unbounded queues hide the bug until they OOM the process.
Consumers loop on q.get() forever. How do you tell them to stop? Put a special
sentinel value โ conventionally None โ into the queue. When a consumer
pulls the sentinel, it breaks out of its loop. Send one sentinel per consumer.
SENTINEL = None
def consumer(q):
while True:
item = q.get()
if item is SENTINEL: # our signal to stop
break
process(item)
# ... after all real work is enqueued, tell each worker to quit:
for _ in range(num_workers):
q.put(SENTINEL)
get(). You need N sentinels for N workers.
task_done() + join()
Sometimes you don't want to stop the workers โ you want the main thread to wait until every
enqueued item has been fully processed. That's what q.task_done() and
q.join() are for. They track an internal count of "unfinished tasks":
put() increments the unfinished-task count.q.task_done() after finishing each item (once per get()).q.join() blocks until the count hits zero โ i.e. everything put has been marked done.def consumer(q):
while True:
item = q.get()
try:
process(item)
finally:
q.task_done() # ALWAYS mark done, even if process() raised
# main thread
for item in work:
q.put(item)
q.join() # block until every item has had task_done() called
print("all work processed")
join()/task_done() answers "is all the
work done?"; the sentinel answers "should the workers exit?" Real code often uses both:
q.join() to wait for completion, then push sentinels to shut the daemons down cleanly.
join(), then stop them with sentinels. This is the canonical
thread-pool-by-hand.
import queue
import threading
import time
NUM_WORKERS = 4
SENTINEL = object() # a unique, unmistakable stop signal
q = queue.Queue(maxsize=50) # bounded โ backpressure
results = queue.Queue() # collect results thread-safely too
def worker(worker_id):
while True:
item = q.get()
try:
if item is SENTINEL:
return # clean exit
time.sleep(0.1) # pretend this is I/O (GIL released)
results.put((worker_id, item, item * item))
finally:
q.task_done() # one task_done per get(), always
# start the pool
threads = [
threading.Thread(target=worker, args=(i,), daemon=True)
for i in range(NUM_WORKERS)
]
for t in threads:
t.start()
# produce work
for n in range(20):
q.put(n) # blocks if 50 items are already buffered
q.join() # wait until all 20 items are processed
# now shut the workers down: one sentinel each
for _ in range(NUM_WORKERS):
q.put(SENTINEL)
for t in threads:
t.join()
# drain results
while not results.empty():
print(results.get())
print("done")
Note how a second Queue is used to collect results: because Queue is
thread-safe, workers can all put() into it without any lock of your own. Compare this with
the shared-dict approach from Pattern 1 โ same idea, but the
queue removes even the "write different keys" caveat.
LifoQueue & PriorityQueue
The queue module ships two other flavours with the same thread-safe API
(put/get/task_done/join):
queue.LifoQueue โ last-in-first-out, i.e. a thread-safe stack.queue.PriorityQueue โ get() returns the lowest item first. Put tuples like (priority, data); ties are broken by comparing the next element, so include a monotonic counter to avoid comparing unorderable payloads.import queue, itertools
pq = queue.PriorityQueue()
counter = itertools.count() # tiebreaker so payloads are never compared
pq.put((2, next(counter), "low priority task"))
pq.put((0, next(counter), "URGENT task"))
_, _, task = pq.get() # -> "URGENT task" (priority 0 comes out first)
| Tool | What it's for |
|---|---|
queue.Queue | Thread-safe FIFO handoff โ no manual locks needed |
put() / get() | Block by default; give backpressure and "wait for work" |
maxsize=N | Bound the buffer so a fast producer can't OOM you |
Sentinel (None) | Tell workers to exit โ one per consumer |
task_done() + join() | Wait until all enqueued work is fully processed |
Lifo/PriorityQueue | Same API, different ordering |