51 · Threading: Thread, Lock, Queue
Create and join OS threads with threading.Thread, guard shared state with Lock, and connect producers to consumers safely with queue.Queue — the three primitives every I/O-bound concurrent program needs.
`threading.Thread` is the raw building block for concurrent I/O in Python. Three idioms cover 90% of real use cases: `Thread(target=fn).start()` + `.join()` to launch and wait; `with lock:` to protect any shared mutable state; and `queue.Queue` to hand work between threads without ever touching a shared variable directly. Master these three and you understand the thread safety model that PyTorch DataLoader, Gunicorn, and every async web server is built on.
Without this:
Without `Lock` around shared mutable state you get race conditions — two threads reading and incrementing a counter simultaneously can silently produce a wrong total. Without `queue.Queue` you end up polling a shared list with a busy-wait loop, wasting CPU and introducing subtle ordering bugs. Both failure modes are invisible until load or timing changes.
threading.Thread — the basics
The simplest thread API:
from threading import Thread
def work(n):
print(f"thread {n} running")
t = Thread(target=work, args=(42,))
t.start() # launch the thread
t.join() # block the caller until t finishes
Thread(target=fn, args=(...))— supply a callable and its positional args..start()— hands the thread to the OS scheduler; returns immediately..join()— blocks the calling thread until the target thread completes. Forgetting.join()means your main thread exits (and kills all daemon threads) before the workers are done.daemon=True— a daemon thread is killed automatically when no non-daemon threads remain. Useful for background tasks that should not prevent program exit.
Race conditions and Lock
A race condition occurs when two or more threads read a shared variable, compute a new value, and write back — and the OS preempts one thread between the read and the write. The canonical example is two threads each incrementing a counter:
Thread 1 reads counter=100
Thread 2 reads counter=100 ← preemption happened here
Thread 1 writes counter=101
Thread 2 writes counter=101 ← lost the +1 from Thread 1
The fix: a threading.Lock (mutual exclusion). At most one thread holds the lock at a time; any other thread that requests it blocks until it's released.
from threading import Lock
lock = Lock()
with lock: # acquires the lock; releases on exit (even on exception)
counter += 1 # now this is atomic — only one thread at a time
RLock (re-entrant lock) allows the same thread to acquire the lock multiple times without deadlocking — useful in recursive code.
Python (in browser)
In the browser sandbox you'll see `200 000` because Pyodide threads execute sequentially. Run the same code in local CPython with multiple OS threads and you'll see a lower number — increments were silently lost when the OS preempted a thread mid-operation.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
Python (in browser)
`with lock:` guarantees only one thread can be inside the block at a time. The trade-off: lock contention adds latency — if every increment requires acquiring a lock, threads spend time waiting on each other. For a simple counter, `threading.Lock` is the right tool. For higher-level workflows, prefer `queue.Queue` which internalises its own locking.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
Python (in browser)
`queue.Queue.get()` blocks until an item is available — no busy-wait polling loop needed. `queue.Queue.put()` blocks if the queue is full (you can set `maxsize=N` for backpressure). The sentinel pattern (`put(None)`) is the idiomatic way to signal a consumer to stop. `task_done()` + `Queue.join()` lets you wait until every item has been processed — useful for batch pipelines.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
`threading.local()` is the right tool whenever you need state that is *logically global within a thread* but must not bleed into other threads. SQLAlchemy's session scoping and Flask's `g` object both use this mechanism under the hood.
What does `thread.join()` do?
- `Thread(target=fn, args=(...)).start()` launches a thread; `.join()` blocks the caller until it finishes. Always `.join()` unless the thread is `daemon=True`.
- A **race condition** occurs when two threads read-modify-write a shared variable without synchronization. Use `with lock:` (a `threading.Lock`) to make the operation atomic.
- `queue.Queue` is thread-safe by design — no manual locking needed. Use the sentinel pattern (`queue.put(None)`) to signal a consumer thread to stop.
- `threading.local()` provides per-thread storage — each thread sees its own copy of attributes. Ideal for DB connections, request contexts, and per-worker RNG seeds.
- Prefer `queue.Queue` over shared state. If you must share state, every access (reads too) must be inside `with lock:`.
PyTorch's `DataLoader(num_workers=N)` creates N worker threads that read and augment data batches from disk while the GPU is computing the previous batch — a classic I/O-bound thread workload. Web scrapers that collect training data from REST APIs are perfect thread territory: each thread sends a request and blocks waiting for the response (releasing the GIL), while other threads are already sending their requests. `threading.local()` is used inside many ML serving frameworks to hold per-request state without a global lock.
If you remove it: Without `Lock` and `Queue` knowledge, shared-state bugs in data loaders and inference servers are nearly impossible to reproduce — they appear only under load and produce subtly wrong results (corrupted batch indices, mangled tensors, wrong loss aggregates) rather than crashes. These bugs can silently degrade model accuracy or throughput for days before anyone notices.