๐Ÿ“ฌ Pattern 3: Thread-Safe Queue

Stop sharing variables and passing locks around. Hand work between threads through a queue instead.

The Problem: Handing Work Between Threads

Scenario: one thread produces items (URLs to fetch, rows to process) and several other threads consume them. If they share a plain 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.

Key insight: a queue turns "shared mutable state guarded by locks" into "message passing." Threads communicate by handing off ownership of an item rather than by simultaneously touching the same object. That's far easier to reason about and to get right.

The Producer/Consumer Model

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.

flowchart LR P1["Producer 1"] --> Q P2["Producer 2"] --> Q Q{{"queue.Queue
(thread-safe FIFO)"}} Q --> C1["Consumer 1"] Q --> C2["Consumer 2"] Q --> C3["Consumer 3"]

put() and get() Block by Default

This 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.

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 Free

Pattern: pass Queue(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)
Rule of thumb: in production, always set a maxsize unless you can prove consumers keep up. Unbounded queues hide the bug until they OOM the process.

Shutdown: The Sentinel Idiom

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)
Common bug: sending only one sentinel for N consumers. The first consumer to grab it stops; the others block forever on get(). You need N sentinels for N workers.

The Other Shutdown: 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":

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")
Two orthogonal tools: 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.

Full Example: A Worker Pool

Pattern: spin up N consumer threads that all pull from one queue, feed them work, wait for completion with 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.

Beyond FIFO: LifoQueue & PriorityQueue

The queue module ships two other flavours with the same thread-safe API (put/get/task_done/join):

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)

Key Takeaways

ToolWhat it's for
queue.QueueThread-safe FIFO handoff โ€” no manual locks needed
put() / get()Block by default; give backpressure and "wait for work"
maxsize=NBound 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/PriorityQueueSame API, different ordering
Real-world: hand-rolling a worker pool is great for a long-lived pipeline (a scraper, an ingestion service) where you control shutdown. For fire-and-collect batches of tasks, the ThreadPoolExecutor (Pattern 4) wraps this whole pattern โ€” pool, queue, and result handling โ€” behind a much smaller API.