What a thread actually is, why Python threading is weird (hello, GIL), and how the machinery works โ before you touch a single pattern.
A thread is an independent path of execution inside a single process. Every process starts with one (the main thread). You can spawn more, and they all share the same memory: the same variables, the same objects, the same open files.
Same core reason as async: most programs spend their time waiting โ on the network,
the disk, a database. While one thread waits, another can run. You get overlap without rewriting
everything into async/await.
import threading, time
def download(name):
print(f"{name} start")
time.sleep(1) # simulate network wait
print(f"{name} done")
threads = [threading.Thread(target=download, args=(f"file-{i}",)) for i in range(3)]
for t in threads: t.start() # kick them all off
for t in threads: t.join() # wait for all to finish
# total โ 1 second, not 3
CPython has a Global Interpreter Lock โ a single lock that lets only one thread execute Python bytecode at a time. Even on a 16-core machine, your Python threads take turns; they do not run Python code truly in parallel.
time.sleep) and inside many C extensions (NumPy). So:
multiprocessing instead.This is the single most important thing to understand about Python threading. There's a whole deep-dive on the GIL โ read it after this.
Because threads share memory and can be paused anywhere (the OS switches them preemptively,
unlike async's cooperative await), two threads updating the same value can corrupt it.
counter = 0
def increment():
global counter
for _ in range(1_000_000):
counter += 1 # NOT atomic: read, add, write โ can be interrupted mid-way
threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # Expected 2,000,000 โ but you'll get less. Lost updates!
counter += 1 is three steps under the hood (read โ add โ store).
A thread can be paused between them, another thread reads the stale value, and one update is
lost. The fix is a Lock (Pattern 2).
Both give concurrency on I/O. The difference is who decides when to switch:
| Threads | Async | |
|---|---|---|
| Switching | Preemptive โ the OS interrupts anywhere, anytime | Cooperative โ only at await |
| Race conditions | Everywhere โ need locks | Rare โ switches are predictable |
| Cost per unit | Heavy (~MBs of stack, OS-scheduled) | Cheap (thousands of tasks fine) |
| Works with blocking libs | โ
Yes (requests, etc.) | โ Needs async libraries |
threading.Thread โ create, start(), join() a thread. (Pattern 1)Lock / RLock โ make a section of code run one-thread-at-a-time. (Pattern 2)queue.Queue โ the thread-safe way to hand work between threads. (Pattern 3)ThreadPoolExecutor โ the modern, high-level way to run many tasks. (Pattern 4)Event, Condition, Semaphore, Barrier โ coordination primitives. (Pattern 5)ThreadPoolExecutor and
queue.Queue first. Raw Thread + Lock is worth understanding, but
the high-level tools prevent most bugs.
A thread is a lightweight worker inside your process that shares all memory with the
others. Threads let I/O-bound work overlap because the GIL is released while
waiting โ but they can't speed up pure-Python CPU work, and they can be interrupted anywhere,
so shared state needs locks. You create them with threading.Thread,
coordinate them with queues and locks, and in practice run them through a
ThreadPoolExecutor. Master the GIL and race conditions and the rest is
just applying that safely.