You have eight cores. Your Python program uses one. So you reach for threads — the thing every other language uses to spread work across cores — you split the job across eight of them, you run it, and it comes back exactly as slow as before. Sometimes slower. Nothing crashed, nothing errored, the CPU meter shows one core pinned at 100% and seven asleep. You did everything right and got nothing.
This is the single most confusing thing about Python, and it has one cause with a three-letter name: the GIL. Understand it and the whole picture snaps into focus — you will know, before you write a line, whether threads will help, whether you need processes instead, and why “just add more threads” is sometimes the exact wrong move.
Here is the one sentence the rest of the lesson unpacks and then measures: threads give you no speedup for CPU-bound work, but a huge speedup for I/O-bound work; processes give you real multi-core parallelism for CPU-bound work, at the cost of copying everything. Every number below is real, measured on the machine writing this lesson (an 8-CPU laptop — 4 fast “performance” cores plus 4 “efficiency” cores — running Python 3.12.3). Your numbers will differ; the ratios are the lesson.
Why this matters
Concurrency is where competent Python programmers write their most confident wrong code. The APIs are friendly, the examples in the docs run fine, and the failure mode is not a crash — it is a program that is silently the wrong speed, or worse, silently the wrong answer. Both failures are invisible until you measure, and both trace back to one design decision CPython made in 1992 and has never fully undone.
The first trap is the one above: using threads for CPU-bound work and getting no speedup. People add threads, see no improvement, conclude “Python is slow,” and move on. Python is not slow here — it is serialising your threads on purpose, and the fix is not more threads, it is a completely different tool. Getting this right can turn a six-second job into a one-and-a-half-second job on the same laptop. Getting it wrong wastes the seven idle cores you paid for.
The second trap is the opposite and nastier: using threads for shared mutable state and getting the wrong answer. Two threads incrementing the same counter can lose updates — not theoretically, but tens of thousands of them, every run, as you will see measured below. The GIL protects Python’s internals from corruption; it does not protect your data. That distinction is the difference between a program that works and a bank that loses money.
The mental model that prevents both is small. There is a lock — the GIL — that a thread must hold to run Python bytecode, and only one thread can hold it at a time. When a thread does I/O (reads a socket, sleeps, waits on a database) it drops the lock so another thread can run. So threads overlap beautifully when they spend their time waiting, and not at all when they spend their time computing. Processes sidestep the whole thing by each having their own interpreter and their own GIL — genuine parallelism, paid for in memory and in the cost of shipping data between them. Hold those two pictures and you can choose correctly every time.
The GIL: one mutex, one running thread
The Global Interpreter Lock is a single mutex inside the CPython interpreter. The rule it enforces is exactly one sentence: a thread must hold the GIL to execute Python bytecode, and only one thread can hold it at a time. You can have twenty threads, all Runnable, all scheduled by the OS onto twenty different cores — and nineteen of them are blocked on the GIL while one runs. The others are not doing Python work; they are waiting in line for the lock.
That is the whole mechanism. Everything surprising about Python concurrency is a consequence of it.
Why CPython has a GIL
This looks like a design flaw until you see what it buys. Every Python object carries a reference count — a small integer, ob_refcnt, tracking how many names point at it. When the count hits zero the object is freed. Almost every operation touches these counts: assigning a variable, passing an argument, returning a value, appending to a list. If two threads incremented and decremented the same object’s refcount at the same time without synchronisation, the count would corrupt — an object freed while still in use (a crash) or never freed (a leak).
CPython has two ways to make refcounting safe. One: put a tiny lock around every object — millions of locks, taken and released constantly. Two: put one lock around the whole interpreter. Option two is the GIL, and for single-threaded code — which is most Python code — it is dramatically faster, because taking one uncontended lock occasionally is far cheaper than taking millions of fine-grained locks constantly. The GIL also makes C extensions simple: an extension author can assume the interpreter is not being re-entered by another thread, so they can touch Python objects without their own locking. That assumption is baked into thirty years of NumPy, pandas, lxml, database drivers, and image libraries — which is the real reason removing the GIL took until 2023 to even attempt.
| Question | Answer |
|---|---|
| What is the GIL? | One mutex; a thread must hold it to run Python bytecode |
| How many threads run bytecode at once? | Exactly one, no matter how many cores or threads |
| Why does it exist? | Makes reference counting safe with one lock instead of millions; keeps C extensions simple |
| Does it make single-threaded code slower? | No — it makes it faster than fine-grained locking would |
| Does it protect my data from races? | No. It protects the interpreter’s internals, not your objects |
| Do other Pythons have it? | CPython and PyPy do. Jython and IronPython do not. It is not in the language spec |
| Can I turn it off? | Experimentally, in the 3.13+ free-threaded build — see the last section |
The GIL is released constantly
If one thread held the GIL forever, multithreading would be pointless. It doesn’t. The GIL is dropped in three situations, and the middle one is the entire reason threads are useful:
| The GIL is released… | When | Effect |
|---|---|---|
| Every ~5 ms | The interpreter checks an “eval breaker” and voluntarily drops the GIL so another thread can grab it | Fairness between CPU-bound threads — but no extra throughput |
| During blocking I/O | time.sleep, socket/file reads, input(), subprocess, DB waits — the C code wraps the call in Py_BEGIN_ALLOW_THREADS |
Other threads run while this one waits. This is where threading wins |
| Inside many C extensions | hashlib, zlib, numpy, pandas, lxml, compression, crypto — during heavy compute they explicitly release it |
CPU work that is not Python bytecode can run in parallel |
The 5 ms figure is sys.getswitchinterval(); you can read and set it. (Before Python 3.2 the switch was every 100 bytecodes, via the now-removed sys.getcheckinterval — you will see the old name in ancient answers online.)
import sys
print(sys.getswitchinterval()) # => 0.005 seconds between GIL hand-offs
Before the mechanics, clear out the five beliefs that cause the most wasted effort:
| Common belief | Reality |
|---|---|
| “The GIL makes my threaded code thread-safe” | No — it protects interpreter internals (refcounts), not your data. Races are yours to prevent |
| “Threads speed up any slow code” | Only I/O-bound (and GIL-releasing C-extension) work. Pure-Python CPU work sees ~1.0x |
| “More threads = more speed” | Past a point, more threads means more context-switch overhead and slower |
| “The GIL is part of the Python language” | No — it is a CPython implementation detail. Jython and IronPython have none |
| “Removing the GIL is a free win” | It exposes hidden races and (today) slows single-threaded code — see PEP 703 |
Here is the model in one picture. Read it left to right: a workload of eight tasks arrives; the threading path runs them in one interpreter behind one GIL, so they take turns — which is useless for CPU work (measured ~1.0x) and a big win for I/O (measured 15.9x, because the GIL is released while each thread waits); the multiprocessing path runs them in eight separate interpreters, each with its own GIL, on separate cores — genuine parallelism (~4x here) paid for by pickling every argument.
The six badges mark where people lose the most time. The GIL is one mutex with one holder (1), and threads take fairness turns every 5 ms (2) — so CPU-bound threads measure ~1.0x and no lock will change that (3), while I/O-bound threads measure a near-linear win because waiting releases the GIL (4). Real parallelism needs separate processes, each its own interpreter and GIL — where the if __name__ == "__main__" guard is mandatory on the spawn start method (5) — and the price you pay is that every argument and result must pickle and cross an IPC boundary (6).
Threading: no help for CPU, huge help for I/O
Enough theory. Let us measure it, because measuring is the only way to believe it. The threading module gives you Thread objects; concurrent.futures.ThreadPoolExecutor gives you a pool of them with a nicer API. We will use the pool for the measurement and drop to raw Thread right after.
The CPU-bound experiment: threads do nothing
Our workload is deliberately pure Python — counting primes by trial division — so it holds the GIL the entire time and never releases it:
import time, os
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def count_primes(limit):
"""Pure-Python CPU work -> holds the GIL start to finish."""
count = 0
for n in range(2, limit):
is_p = True
for d in range(2, int(n ** 0.5) + 1):
if n % d == 0:
is_p = False
break
if is_p:
count += 1
return count
CHUNKS = [500_000] * 8 # eight equal chunks of CPU work
def timed(label, fn):
t0 = time.perf_counter()
fn()
dt = time.perf_counter() - t0
print(f"{label:<26} {dt:6.2f}s")
return dt
if __name__ == "__main__": # required — see multiprocessing, below
print(f"CPUs: {os.cpu_count()}")
s = timed("serial", lambda: [count_primes(c) for c in CHUNKS])
t = timed("ThreadPool(8)", lambda: list(ThreadPoolExecutor(8).map(count_primes, CHUNKS)))
p = timed("ProcessPool(8)", lambda: list(ProcessPoolExecutor(8).map(count_primes, CHUNKS)))
print(f"threads speedup: {s/t:.2f}x processes speedup: {s/p:.2f}x")
CPUs: 8
serial 5.68s
ThreadPool(8) 5.77s
ProcessPool(8) 1.27s
threads speedup: 0.99x processes speedup: 4.46x
Look hard at that. Eight threads on an eight-CPU machine ran the CPU-bound job at 0.99x — no faster than one thread, in fact a hair slower because of the switching overhead. This is not a fluke or a bad benchmark; it is the GIL doing exactly its job. The eight threads spent the whole run passing one lock back and forth, and only one ever computed at a time. The ProcessPool on the same code and same data hit 4.46x — that is the next section’s story.
Run it three times and you will see the threads number hover around 1.0x (0.99x–1.06x on this machine) and the process number bounce between about 3.6x and 4.5x. Timings vary run to run — background load, thermal throttling, and scheduling all move them — so always report a ratio from a representative run, never a single magic number.
Why 4x and not 8x on eight CPUs? Because this laptop has 4 performance cores and 4 slower efficiency cores; the heavy lifting lands on the four fast ones, plus process startup and a little imbalance eat the rest. That honesty matters: process parallelism scales toward your core count, not past it, and rarely reaches it perfectly. For the theory of why the serial-fraction of a job caps your speedup (Amdahl’s law), see Algorithms: search, sort & complexity.
The I/O-bound experiment: threads win big
Now swap the workload for something that waits. time.sleep releases the GIL — it is a stand-in for a network request, a database query, a disk read, anything where your program hands control to the OS and waits:
import time
from concurrent.futures import ThreadPoolExecutor
def fetch(url):
time.sleep(0.5) # a 0.5s "network round-trip" — RELEASES the GIL
return f"{url} -> 200"
URLS = [f"https://api.example.com/item/{i}" for i in range(16)]
def timed(label, fn):
t0 = time.perf_counter(); fn(); dt = time.perf_counter() - t0
print(f"{label:<26} {dt:6.2f}s"); return dt
if __name__ == "__main__":
s = timed("serial", lambda: [fetch(u) for u in URLS])
t = timed("ThreadPool(16)", lambda: list(ThreadPoolExecutor(16).map(fetch, URLS)))
print(f"threads speedup: {s/t:.2f}x")
serial 8.06s
ThreadPool(16) 0.51s
threads speedup: 15.93x
15.93x. Sixteen requests that took 8 seconds one at a time finished in half a second when overlapped. The moment fetch calls time.sleep, its thread drops the GIL, another thread runs, calls sleep, drops the GIL, and so on — all sixteen end up waiting simultaneously, so the total wall-clock time is one sleep, not sixteen. This is the entire case for threading: when your threads spend their time waiting, they wait in parallel. Real HTTP with the requests library behaves the same way — the socket read releases the GIL.
The exception that proves the rule: C extensions
“Threads give no CPU speedup” has one important caveat. Some CPU-heavy work is done inside a C extension that releases the GIL while it crunches. Hashing is the classic example — hashlib drops the GIL for large inputs:
import time, hashlib
from concurrent.futures import ThreadPoolExecutor
BLOB = b"x" * (60 * 1024 * 1024) # 60 MB
def heavy_hash(_):
h = hashlib.sha256()
for _ in range(8):
h.update(BLOB) # hashlib releases the GIL for big buffers
return h.hexdigest()[:8]
if __name__ == "__main__":
tasks = list(range(8))
t0 = time.perf_counter(); [heavy_hash(t) for t in tasks]
print(f"serial: {time.perf_counter()-t0:.2f}s")
t0 = time.perf_counter()
with ThreadPoolExecutor(8) as ex: list(ex.map(heavy_hash, tasks))
print(f"threads: {time.perf_counter()-t0:.2f}s")
serial: 4.33s
threads: 1.44s
That is 3.01x from threads on CPU-bound work — because the actual computation happens in C with the GIL released. This is why numpy, pandas, scipy and Pillow operations can genuinely parallelise across threads: the heavy loop is not Python bytecode. The rule is really: pure-Python CPU work gets no thread speedup. If your hot path is a C extension that releases the GIL, threads may be all you need.
| Workload (this machine) | Serial | Threads | Speedup | Why |
|---|---|---|---|---|
| Pure-Python CPU (count primes) | 5.68s | 5.77s | 0.99x | GIL held throughout; threads serialise |
| I/O-bound (16 × 0.5s sleep) | 8.06s | 0.51s | 15.93x | GIL released while waiting; waits overlap |
| C-extension CPU (sha256 × 8) | 4.33s | 1.44s | 3.01x | C code releases the GIL during compute |
The raw threading.Thread API
The pool hides the machinery. Underneath, a thread is a Thread object you start() and join():
import threading, time
def worker(name, seconds):
print(f" {name} starting")
time.sleep(seconds)
print(f" {name} done")
t1 = threading.Thread(target=worker, args=("A", 0.3))
t2 = threading.Thread(target=worker, args=("B", 0.1))
t1.start(); t2.start() # both now running concurrently
print("threads launched, main is free")
t1.join(); t2.join() # block until BOTH have finished
print("all joined")
A starting
B starting
threads launched, main is free
B done
A done
all joined
start() launches the thread and returns immediately; join() blocks the caller until that thread finishes. Notice B finished before A (it slept less) and that “main is free” printed before either worker completed — that is concurrency. A few rules and the API surface:
| Call | What it does | Gotcha |
|---|---|---|
Thread(target=fn, args=(...), kwargs={...}) |
Build a thread that will run fn(*args, **kwargs) |
Does not start it — start() does |
.start() |
Launch the thread; runs run() in the new thread |
Calling twice → RuntimeError: threads can only be started once |
.join(timeout=None) |
Block until the thread finishes (or timeout s) |
Returns None; check .is_alive() to see if it timed out |
.is_alive() |
True between start() and the target returning |
— |
.daemon (set before start) |
True → interpreter won’t wait for it at exit |
Daemon threads are killed at exit — work may be lost |
.name, .ident, .native_id |
Debug labels / OS thread id | ident is None until started |
threading.current_thread(), .active_count() |
Introspection | Handy in logs |
A Thread has no built-in return value. The target’s return is discarded. To get a result out you either write into a shared structure (and now you have a race — see the next section), use a queue.Queue, or — far better — use concurrent.futures, whose Future.result() hands you the return value and re-raises any exception. This is the main reason to prefer the executor over raw threads.
Daemon threads: killed at exit
A daemon thread does not keep the program alive. When the main thread exits, daemon threads are terminated where they stand — mid-line, no cleanup:
import threading, time
done = []
def slow_work():
time.sleep(1.0)
done.append("finished") # may NEVER run
threading.Thread(target=slow_work, daemon=True).start()
time.sleep(0.2) # main exits long before slow_work finishes
print("main exiting; done =", done)
main exiting; done = []
The daemon was killed at 0.2 s, one-fifth of the way through its second-long job, and its result was lost. Daemon threads are right for fire-and-forget background chores (a heartbeat, a metrics flusher) where losing the last iteration is fine. They are wrong for anything that must complete — for that, keep the thread non-daemon and join() it, or hand the work to a pool and shut it down cleanly.
Race conditions and the tools that fix them
The GIL protects Python’s internals. It does not protect your data — and the proof is a lost update you can measure. This is the part that turns “concurrency is confusing” into “concurrency is dangerous.”
A race condition, measured
counter += 1 looks atomic. It is not — it compiles to read the value, add one, write it back, three separate steps. If a thread is paused between the read and the write, another thread can read the same old value, and one of the two increments vanishes. Real code almost always does something between reading a shared value and writing it back — validate it, log it, compute the new one — and that gap is the whole vulnerability. We make it visible with time.sleep(0) (an explicit “let another thread run here”) and shrink the switch interval so the bug reproduces on every run instead of once a fortnight:
import sys, threading, time
sys.setswitchinterval(1e-6) # switch threads often, so the race shows EVERY run
# (the bug exists at the default 5ms too — just rarely)
counter = 0
def deposit(n):
global counter
for _ in range(n):
current = counter # READ the shared value
time.sleep(0) # ...any work here = a window for another thread
counter = current + 1 # WRITE back a now-stale value
threads = [threading.Thread(target=deposit, args=(25_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"expected 100,000, counter is {counter:,} (lost {100_000 - counter:,})")
expected 100,000, counter is 25,816 (lost 74,184)
Four threads each added 25,000. The counter should read 100,000. It reads 25,816 — three-quarters of the updates were silently lost, and it lands near 25,000 every run because the threads spend the whole time clobbering each other’s writes. No exception, no warning; just a number that is wrong. In a bank, those are missing deposits. The GIL did nothing to save you, because incrementing your counter is your logic, not the interpreter’s.
Lock: the fix
A threading.Lock makes a section of code mutually exclusive — only one thread inside it at a time. Wrap the read-modify-write and the race is gone:
import sys, threading, time
sys.setswitchinterval(1e-6)
counter = 0
lock = threading.Lock()
def deposit(n):
global counter
for _ in range(n):
with lock: # only one thread past this line at a time
current = counter
time.sleep(0) # even with the gap, the lock holds the door shut
counter = current + 1
threads = [threading.Thread(target=deposit, args=(25_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"expected 100,000, counter is {counter:,} (lost {100_000 - counter:,})")
expected 100,000, counter is 100,000 (lost 0)
Exactly 100,000, every time. The with lock: block is a critical section: a thread acquires the lock on the way in and releases it on the way out (even if the body raises — that is why with is the right tool, and the same guarantee you get from any context manager). Everything inside is serialised. The cost is contention — threads queue for the lock — so keep critical sections small: lock only the shared mutation, not the whole function.
Always prefer with lock: over manual acquire()/release(). The manual form leaks the lock forever if the body raises before release():
lock.acquire()
risky() # if this raises, release() below never runs -> deadlock
lock.release() # DON'T. Use `with lock:` which releases on any exit
The synchronisation toolbox
Lock is the workhorse, but threading gives you six primitives, each for a different coordination shape:
| Primitive | Use it when | Key methods |
|---|---|---|
Lock |
One thread at a time in a critical section (the default choice) | acquire(), release(), with lock: |
RLock |
The same thread must re-acquire a lock it already holds (recursive code) | Same, but re-entrant by the owning thread |
Semaphore(n) |
Allow up to n threads at once (a connection pool, a rate limit) | acquire(), release(), with sem: |
Event |
One thread signals; others wait for a flag to flip | set(), clear(), wait(), is_set() |
Condition |
Wait until state changes (classic producer/consumer) | wait(), notify(), notify_all() |
Barrier(n) |
All n threads must arrive before any proceeds | wait() |
RLock earns its keep the moment one locked method calls another. A plain Lock would deadlock a thread against itself:
import threading
lock = threading.Lock()
def outer():
with lock:
inner() # inner() also wants the lock...
def inner():
with lock: # ...same thread, plain Lock -> HANGS forever
pass
# outer() # would deadlock. Swap threading.Lock() for threading.RLock() and it works.
An RLock remembers which thread owns it and how many times, so the same thread sails through; a different thread still blocks. Semaphore is the tool when the rule is “at most N at once” rather than “exactly one” — throttling concurrent downloads to five, say. Event is a one-shot (or resettable) broadcast: workers wait() until a coordinator calls set(). Condition is for the pattern “sleep until there is something to do, then wake up” without busy-looping.
queue.Queue: share work, not state
Here is the pattern that lets you skip most of the above. Do not share mutable state between threads; pass messages through a thread-safe queue. queue.Queue handles all the locking internally, so your worker code has no locks at all:
import threading, queue
work = queue.Queue()
results = queue.Queue()
def worker():
while True:
item = work.get() # blocks until an item is available
if item is None: # the sentinel: "no more work"
work.task_done()
break
results.put(item * item)
work.task_done() # tell the queue this item is finished
threads = [threading.Thread(target=worker) for _ in range(4)]
for t in threads: t.start()
for n in range(1, 9): work.put(n) # produce work
for _ in threads: work.put(None) # one sentinel per worker, to stop them
work.join() # block until every item is task_done()
for t in threads: t.join()
print(sorted(results.queue)) # => [1, 4, 9, 16, 25, 36, 49, 64]
[1, 4, 9, 16, 25, 36, 49, 64]
No Lock anywhere in the worker — the queue is the synchronisation. get() blocks a worker until there is something to do; put() is safe from any thread; task_done() and join() let the producer wait for completion. The critical detail is the sentinel: you must send one None per worker to tell them to stop, or the workers sit in work.get() forever after the last real item — a hang with no error. That is the number-one queue bug, and it has its own row in the troubleshooting table.
| Queue call | Meaning |
|---|---|
q.put(x) / q.put(x, timeout=t) |
Add an item (blocks if maxsize reached) |
q.get() / q.get(timeout=t) |
Remove and return an item (blocks if empty) |
q.get_nowait() / q.put_nowait() |
Non-blocking; raise queue.Empty / queue.Full instead of waiting |
q.task_done() |
Signal that one get()-ed item is fully processed |
q.join() |
Block until every put item has been task_done() |
queue.Queue / LifoQueue / PriorityQueue |
FIFO / stack / priority ordering |
queue.SimpleQueue |
Simpler, faster, unbounded FIFO (3.7+) — no task_done/join |
Deadlock: two locks, opposite order
Locks solve races and introduce a new failure: deadlock, where two threads each hold a lock the other needs, and both wait forever. The textbook cause is two locks acquired in opposite order — the dining philosophers, reaching for forks:
import threading, time
fork_left = threading.Lock()
fork_right = threading.Lock()
def alice():
with fork_left: # Alice grabs LEFT first
time.sleep(0.1)
with fork_right: # ...then waits for RIGHT (Bob holds it)
print("Alice ate")
def bob():
with fork_right: # Bob grabs RIGHT first
time.sleep(0.1)
with fork_left: # ...then waits for LEFT (Alice holds it)
print("Bob ate")
a = threading.Thread(target=alice, daemon=True)
b = threading.Thread(target=bob, daemon=True)
a.start(); b.start()
a.join(timeout=2.0); b.join(timeout=2.0) # watchdog — or we'd hang forever
if a.is_alive() or b.is_alive():
print("DEADLOCK: both threads stuck after 2s")
DEADLOCK: both threads stuck after 2s
Both threads grabbed their first fork, slept, and reached for the second — which the other thread was holding. Neither will ever let go. (Note the daemon=True and the join(timeout=...) watchdog: without them this program hangs forever, which is exactly what a deadlock does in production.) The fix is a rule so simple it feels like cheating: every thread acquires locks in the same global order. If everyone grabs left before right, no cycle can form:
def eat(name):
with fork_left: # EVERYONE grabs left before right — no cycle possible
time.sleep(0.1)
with fork_right:
print(f"{name} ate")
a = threading.Thread(target=eat, args=("Alice",), daemon=True)
b = threading.Thread(target=eat, args=("Bob",), daemon=True)
a.start(); b.start(); a.join(2.0); b.join(2.0)
print("both finished, no deadlock" if not (a.is_alive() or b.is_alive()) else "DEADLOCK")
Alice ate
Bob ate
both finished, no deadlock
| Deadlock needs all four (Coffman) | Break it by |
|---|---|
| Mutual exclusion — locks are exclusive | (usually can’t relax this) |
| Hold and wait — hold one lock, request another | Acquire all locks at once, or none |
| No preemption — can’t force a lock away | Use acquire(timeout=...) and back off on failure |
| Circular wait — a cycle of “waiting for” | Impose a global lock order — the standard fix |
Break any one condition and deadlock is impossible. Global lock ordering breaks the circular wait and is the fix you will reach for most; acquire(timeout=...) (which returns False instead of blocking forever) is the defensive backup.
Multiprocessing: a second interpreter is a second GIL
Threads can’t parallelise CPU work because they share one GIL. Processes each get their own interpreter and their own GIL — so they run genuinely in parallel, on separate cores, with no lock between them. That is the only way to make pure-Python CPU code use all your cores, and it is what turned our prime-counting job from 5.68s to 1.27s.
The API deliberately mirrors threading: Process is to Thread what multiprocessing.Queue is to queue.Queue. The difference is everything underneath — separate memory instead of shared.
| Threading | Multiprocessing | Both give you |
|---|---|---|
threading.Thread |
multiprocessing.Process |
start(), join(), is_alive(), daemon |
queue.Queue |
multiprocessing.Queue |
A safe FIFO with put() / get() |
threading.Lock / Semaphore |
multiprocessing.Lock / Semaphore |
Mutual exclusion between workers |
ThreadPoolExecutor |
ProcessPoolExecutor |
submit, map, as_completed, futures |
| Shared memory (automatic, needs locks) | Pickle + IPC (explicit, no locks) | Moving data between workers |
from multiprocessing import Process
import os
def work(name):
print(f" {name} in pid {os.getpid()}")
if __name__ == "__main__":
print(f"main pid {os.getpid()}")
procs = [Process(target=work, args=(f"P{i}",)) for i in range(3)]
for p in procs: p.start()
for p in procs: p.join()
main pid 51234
P0 in pid 51235
P1 in pid 51236
P2 in pid 51237
Different PIDs — these are real OS processes, not threads. Each has its own memory. And that last fact is the source of every multiprocessing gotcha.
Everything must pickle
Because processes don’t share memory, every argument you pass to a worker and every result it returns must be serialised — converted to bytes, shipped through a pipe, and rebuilt on the other side. CPython uses pickle for this. Most things pickle fine. Some things famously do not:
from multiprocessing import Pool
if __name__ == "__main__":
with Pool(2) as pool:
print(pool.map(lambda x: x * x, range(4))) # a lambda...
_pickle.PicklingError: Can't pickle <function <lambda> at 0x1025a6340>: attribute lookup <lambda> on __main__ failed
Lambdas can’t be pickled (they have no importable name), so they can’t cross to a worker process. Neither can locally-defined functions (AttributeError: Can't pickle local object), open file handles, sockets, database connections, or thread locks. The fix is to use a module-level def as the worker target.
| Pickles fine | Does not pickle |
|---|---|
int, float, str, bytes, bool, None |
lambda (→ PicklingError) |
list, dict, tuple, set of picklables |
Local/nested functions (→ AttributeError: Can't pickle local object) |
| Module-level functions and classes | Open files, sockets, DB connections, threading.Lock |
dataclass instances (of picklable fields) |
Generators, functools.partial of a lambda |
| Most standard-library value types | Anything holding an unpicklable attribute |
The if __name__ == "__main__" guard is mandatory
This is the single most common multiprocessing crash, and on macOS and Windows it is not optional. Those platforms use the spawn start method: to create a worker, Python launches a fresh interpreter that re-imports your module to get the worker function. If creating the pool is at module top level — not guarded — the child re-runs that line too, tries to create its own pool, whose children re-import and create more pools… a fork bomb.
from multiprocessing import Pool
def work(x):
return x * x
pool = Pool(4) # NO guard — runs on every re-import
print(pool.map(work, range(8)))
RuntimeError:
An attempt has been made to start a new process before the
current process has finished its bootstrapping phase.
This probably means that you are not using fork to start your
child processes and you have forgotten to use the proper idiom
in the main module:
if __name__ == '__main__':
freeze_support()
...
Python detects the runaway spawn and raises — but not before thousands of processes try to launch (this run logged over 4,500 failed spawn attempts before it settled). The fix is one line, and it is why every multiprocessing example in this lesson has it:
from multiprocessing import Pool
def work(x):
return x * x
if __name__ == "__main__": # child re-imports the module, but __name__
with Pool(4) as pool: # is NOT "__main__" there, so this block is skipped
print(pool.map(work, range(8)))
# => [0, 1, 4, 9, 16, 25, 36, 49]
The guard means “run this only when the file is executed directly, not when it is imported” — and a spawned child imports it. Put your pool creation, your Process starts, and your top-level driver code inside it.
| Start method | Default on | How a worker is born | Notes |
|---|---|---|---|
spawn |
macOS, Windows | Fresh interpreter, re-imports your module | Slowest start; guard is mandatory; safest |
fork |
Linux | Copies the parent process wholesale | Fast; unsafe if the parent has threads (can deadlock) |
forkserver |
(opt-in, Unix) | A clean server process forks workers | Middle ground; avoids fork-after-threads hazards |
You can choose with multiprocessing.set_start_method("spawn"). Since fork-after-threads is genuinely dangerous, write your code to be spawn-safe (guarded, picklable, no reliance on inherited globals) even on Linux — it is the portable, future-proof default, and CPython is gradually moving Linux toward it.
Pool and the interfaces for passing data
Pool is the high-level workhorse. It starts N worker processes and feeds them tasks:
from multiprocessing import Pool
def square(x): return x * x
def add(a, b): return a + b
if __name__ == "__main__":
with Pool(4) as pool:
print(pool.map(square, range(6))) # blocks, results in order
print(pool.starmap(add, [(1, 2), (3, 4)])) # unpacks tuples as args
r = pool.apply_async(square, (10,)) # non-blocking; returns a handle
print(r.get(timeout=5)) # fetch the one result
[0, 1, 4, 9, 16, 25]
[3, 7]
100
Pool method |
Behaviour |
|---|---|
map(fn, iters) |
Blocks; results in input order; splits work into chunks |
imap(fn, iters) |
Lazy iterator of results, in order — stream huge inputs without buffering all |
imap_unordered(fn, iters) |
Same, but yields results as they finish (fastest-first) |
starmap(fn, iters) |
Like map but unpacks each tuple: fn(*args) |
apply_async(fn, args) |
Submit one task, get an AsyncResult; .get() fetches it |
close() / join() / terminate() |
Stop accepting work / wait / kill immediately |
For moving data between specific processes rather than a pool, there are three levels:
| Mechanism | Shape | Cost | Use for |
|---|---|---|---|
multiprocessing.Queue |
Process-safe FIFO; items pickled through a pipe | Medium (pickle + pipe) | Fan-out work / fan-in results between any processes |
Pipe() |
Two-ended (conn1, conn2); send()/recv() |
Low (one hop) | Fast channel between exactly two processes |
shared_memory.SharedMemory |
A raw byte buffer both processes map | Lowest — no copy | Big arrays (numpy) shared without pickling |
shared_memory (3.8+) is the escape hatch when pickling a large array every call is the bottleneck — you write bytes once and every process reads the same physical memory:
from multiprocessing import shared_memory
import struct
if __name__ == "__main__":
shm = shared_memory.SharedMemory(create=True, size=16)
shm.buf[0:4] = struct.pack("i", 42) # write into the raw buffer
print("read back:", struct.unpack("i", bytes(shm.buf[0:4]))[0])
shm.close()
shm.unlink() # ⚠️ REQUIRED — or the block leaks
read back: 42
⚠️ Shared memory is not garbage-collected. You must close() every handle and unlink() exactly once, or the block survives your program and leaks OS memory. (The OS also rounds the size up to a page — ask for 16 bytes and shm.size may report 16384.) There is also Manager(), which hands out proxied list/dict objects that look shared, but every access is a pickled round-trip to a server process — convenient, and slow; reach for it only for low-frequency coordination.
The cost side of the ledger
Processes are not free, and the costs are exactly why you don’t use them for I/O or for tiny tasks:
| Cost | Threads | Processes |
|---|---|---|
| Startup | Microseconds | Milliseconds (spawn re-imports your module) |
| Memory each | Shares the parent’s | A full interpreter copy (tens of MB) |
| Passing data | Free — shared memory | Pickle + IPC on every argument and result |
| Sharing objects | Automatic (and dangerous) | None by default — separate memory |
| Right for | I/O-bound, C-extension CPU | Pure-Python CPU-bound |
If your tasks are small, the pickling and startup overhead can cost more than the work — which is why the CPU benchmark used 500,000-iteration chunks, not eight tiny ones. Give each process enough work to dwarf the cost of getting it there.
concurrent.futures: one API, swap one word
You have now seen two APIs — threading/ThreadPoolExecutor and multiprocessing/Pool. The beautiful thing about concurrent.futures is that it unifies threads and processes behind one interface, so switching between them is a one-word edit:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def job(x):
return x * x
if __name__ == "__main__":
with ThreadPoolExecutor(max_workers=4) as ex: # threads: for I/O-bound
print(list(ex.map(job, range(6))))
with ProcessPoolExecutor(max_workers=4) as ex: # processes: for CPU-bound
print(list(ex.map(job, range(6)))) # ...identical code
[0, 1, 4, 9, 16, 25]
[0, 1, 4, 9, 16, 25]
The only difference is Thread versus Process in the class name. Write your parallel code once, measure it both ways, keep the winner. That is the practical payoff of everything above.
Futures propagate exceptions correctly
A Future is a handle to a result that may not exist yet. submit() returns one immediately; result() blocks until it is ready — and crucially, re-raises in the caller any exception the worker hit. No swallowed errors:
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def task(n):
time.sleep(0.1 * n)
if n == 3:
raise ValueError("task 3 blew up")
return n * n
if __name__ == "__main__":
with ThreadPoolExecutor(max_workers=4) as ex:
futures = {ex.submit(task, n): n for n in [1, 2, 3, 4]}
for fut in as_completed(futures): # yields each as it FINISHES
n = futures[fut]
try:
print(f"task {n} -> {fut.result()}") # re-raises here if it failed
except ValueError as e:
print(f"task {n} raised: {e}")
task 1 -> 1
task 2 -> 4
task 3 raised: task 3 blew up
task 4 -> 16
The ValueError raised inside a worker did not vanish — it was captured in the future and re-thrown the instant we called .result(), where a normal try/except caught it. Compare this to bare multiprocessing.Pool.apply_async, where an exception in the worker sits silently until you call .get() — a real trap if you forget to. Futures make the error handling ordinary.
| Executor / Future call | Behaviour |
|---|---|
submit(fn, *args) → Future |
Schedule one call; returns immediately |
map(fn, iterable) |
Results in input order; re-raises on iteration |
as_completed(futures) |
Iterator yielding futures in completion order |
wait(futures, return_when=...) |
Block for FIRST_COMPLETED / ALL_COMPLETED |
future.result(timeout=None) |
Block for the value; re-raises the worker’s exception |
future.exception() |
Return the exception instead of raising it |
future.done() / .running() / .cancel() |
State queries; cancel only if not yet started |
executor.shutdown(wait=True) |
Wait for pending work (automatic with with) |
Two default-workers facts worth knowing: ThreadPoolExecutor() defaults to min(32, cpu_count + 4) workers (12 on this machine), tuned for I/O; ProcessPoolExecutor() defaults to cpu_count (8 here), tuned for CPU. map gives you results in the order you submitted; as_completed gives them to you as they finish — use as_completed when you want to react to the fast results without waiting for the slow ones.
Choosing the right tool
Everything reduces to one question you now know how to answer: is the work CPU-bound or I/O-bound? Profile if you are unsure — but usually you know: if it is crunching numbers, parsing, hashing pure-Python, or looping, it is CPU-bound; if it is talking to a network, disk, or database, it is I/O-bound.
| Your workload | Reach for | Why |
|---|---|---|
| Pure-Python CPU-bound (compute, parse, transform) | ProcessPoolExecutor |
Only separate GILs give real multi-core parallelism |
| CPU-bound via a C extension (numpy, hashlib, pandas) | ThreadPoolExecutor |
The extension releases the GIL — threads parallelise it |
| I/O-bound, dozens–hundreds of tasks | ThreadPoolExecutor |
Waiting releases the GIL; simple, no async rewrite |
| I/O-bound, thousands–millions of tasks | asyncio |
One thread, an event loop — no per-task thread cost |
| A mix (fetch then crunch) | Threads/async for the fetch, a process pool for the crunch | Match the tool to each phase |
| Tiny tasks | Often none — stay serial | Coordination overhead can exceed the work |
The three-way choice — threads, processes, asyncio — is the concurrency trilemma, and each corner has a distinct trade-off:
| Threading | Multiprocessing | Asyncio | |
|---|---|---|---|
| Parallel CPU work | ❌ (one GIL) | ✅ (N GILs) | ❌ (one thread) |
| Good for I/O | ✅ | ✅ (heavy) | ✅✅ (lightest) |
| Memory per task | Low | High (a process each) | Lowest (a coroutine) |
| Shares memory | Yes (needs locks) | No (pickle/IPC) | Yes (needs care) |
| Scales to | ~hundreds of threads | ~your core count | tens of thousands |
| Pass data | Shared vars + queue.Queue |
pickle, Queue, Pipe, shared_memory |
shared vars, asyncio.Queue |
| Main hazard | Race conditions, deadlock | Pickling, startup cost, the guard | Blocking the loop |
| Preemptive? | Yes (5 ms) | Yes (OS) | No — cooperative (await points) |
Asyncio is the subject of its own lesson; the one-line summary is that it does I/O concurrency in a single thread using cooperative await points, so it has no locking hazards and scales to tens of thousands of connections — but a single blocking call (a time.sleep, a CPU loop) freezes the whole event loop. For fewer, chunkier I/O tasks, threads are simpler; for massive I/O fan-out, asyncio wins.
PEP 703: the free-threaded future
For thirty years the honest answer to “can I remove the GIL?” was “no.” As of Python 3.13 (October 2024) the honest answer is “yes, experimentally.” PEP 703 adds a free-threaded build of CPython — compiled with --disable-gil — in which the GIL is genuinely gone and pure-Python threads run on all your cores at once. This is the biggest change to Python’s execution model in its history, and it is worth understanding precisely, including its limits.
What changes. On a free-threaded build, the CPU-bound prime benchmark that measured 0.99x on threads would approach the ~4x you currently only get from processes — without pickling, without process startup, with shared memory. The refcounting that the GIL protected is made thread-safe by other means (biased reference counting and internal per-object locks). You can check the runtime state on 3.13+:
import sys
print(sys._is_gil_enabled()) # True on a normal build; False on a free-threaded one
What does not change. Removing the GIL does not remove race conditions — it makes them worse. Today the GIL accidentally serialises many operations, so some buggy code appears to work; without it, every unsynchronised shared write is exposed. You will need locks more, not less. The threading, multiprocessing and concurrent.futures APIs are unchanged; your Locks and Queues are exactly as necessary. And multiprocessing does not become obsolete — process isolation still matters for fault tolerance and for truly independent work.
Why it is not the default. Three reasons. First, single-threaded code runs measurably slower on the free-threaded build (the fine-grained safety has overhead) — an unacceptable regression to impose on everyone. Second, the entire C-extension ecosystem assumed the GIL; each extension must be audited and rebuilt for free-threading (the build even uses a separate ABI, so binaries are often named python3.13t). Third, it is still stabilising. Python 3.14 (2025) upgraded free-threading from “experimental” to officially supported, but it remains opt-in, not the default — you must install and select the free-threaded build deliberately.
| Aspect | GIL build (today’s default) | Free-threaded build (--disable-gil) |
|---|---|---|
| Pure-Python CPU threading | Serial (no speedup) | Parallel across cores |
| Single-thread speed | Baseline | Slightly slower |
| Race conditions | Exist; sometimes masked | Exist; fully exposed — lock more |
| C extensions | All work | Must be rebuilt/marked compatible |
| Status (3.13 / 3.14) | Default, stable | Experimental / supported, opt-in |
sys._is_gil_enabled() |
True |
False |
The pragmatic takeaway for 2026: write the multiprocessing/threading code this lesson teaches. It is correct on today’s default build and stays correct on the free-threaded one. If free-threading becomes the norm, your process pools quietly become thread pools with the same API — and your locks, which you were disciplined about, keep you correct through the transition.
Hands-on lab
This lab is pure standard library — no pip install. (For the habit: python3 -m venv .venv && source .venv/bin/activate; on Windows .venv\Scripts\activate and use python for python3.) It targets Python 3.12+; sys._is_gil_enabled is 3.13+, and some error text differs on older versions. Every measurement below is real — your timings will differ, the ratios are the point — and any process code must live under if __name__ == "__main__".
Create concurrency_lab.py and build it up step by step, running python3 concurrency_lab.py after each.
Step 1 — Prove the GIL: the same CPU job, three ways.
import time, os
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def count_primes(limit): # same explicit loop we measured earlier
count = 0
for n in range(2, limit):
is_p = True
for d in range(2, int(n ** 0.5) + 1):
if n % d == 0:
is_p = False
break
if is_p:
count += 1
return count
CHUNKS = [500_000] * 8
def timed(label, fn):
t0 = time.perf_counter(); r = fn(); dt = time.perf_counter() - t0
print(f" {label:<24} {dt:6.2f}s"); return dt
if __name__ == "__main__":
print(f"CPUs: {os.cpu_count()} | CPU-bound: 8 x count_primes(500_000)")
s = timed("serial", lambda: [count_primes(c) for c in CHUNKS])
t = timed("ThreadPool(8)", lambda: list(ThreadPoolExecutor(8).map(count_primes, CHUNKS)))
p = timed("ProcessPool(8)", lambda: list(ProcessPoolExecutor(8).map(count_primes, CHUNKS)))
print(f" => threads {s/t:.2f}x processes {s/p:.2f}x")
CPUs: 8 | CPU-bound: 8 x count_primes(500_000)
serial 5.68s
ThreadPool(8) 5.77s
ProcessPool(8) 1.27s
=> threads 0.99x processes 4.46x
What just happened: the headline result of the whole lesson, on one screen. Eight threads ran the CPU job at 0.99x — the GIL let only one compute at a time. Eight processes ran the identical code at 4.46x — eight interpreters, eight GILs, real cores. Same work, same data, one word of difference (Thread vs Process), a 4.5x gap.
Step 2 — The opposite result: I/O-bound work.
def fetch(_):
time.sleep(0.5) # simulate a network call — releases the GIL
return 200
if __name__ == "__main__":
print("I/O-bound: 16 x sleep(0.5)")
s = timed("serial", lambda: [fetch(i) for i in range(16)])
t = timed("ThreadPool(16)", lambda: list(ThreadPoolExecutor(16).map(fetch, range(16))))
print(f" => threads {s/t:.2f}x")
I/O-bound: 16 x sleep(0.5)
serial 8.06s
ThreadPool(16) 0.51s
=> threads 15.93x
What just happened: the exact tool that did nothing in Step 1 gave a 15.93x win here. Because sleep releases the GIL, all sixteen waits overlapped and the total was one sleep, not sixteen. This is the whole rule in two steps: threads lose on CPU, win on I/O.
Step 3 — Reproduce a race condition.
import sys, threading
sys.setswitchinterval(1e-6) # make the race surface every run
counter = 0
def bump(n):
global counter
for _ in range(n):
current = counter
time.sleep(0) # the window another thread slips through
counter = current + 1
if __name__ == "__main__":
counter = 0
threads = [threading.Thread(target=bump, args=(25_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"race: expected 100,000, got {counter:,} (lost {100_000 - counter:,})")
race: expected 100,000, got 25,816 (lost 74,184)
What just happened: four threads each added 25,000 and 74,184 updates vanished. The += 1 was three non-atomic steps and the threads clobbered each other’s writes. The GIL did not save you — your counter is your logic, not the interpreter’s.
Step 4 — Fix the race with a Lock.
if __name__ == "__main__":
counter = 0
lock = threading.Lock()
def bump_safe(n):
global counter
for _ in range(n):
with lock:
current = counter
time.sleep(0)
counter = current + 1
threads = [threading.Thread(target=bump_safe, args=(25_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"locked: expected 100,000, got {counter:,} (lost {100_000 - counter:,})")
locked: expected 100,000, got 100,000 (lost 0)
What just happened: wrapping the read-modify-write in with lock: made it a critical section — one thread inside at a time — and the count is now exactly right, every run. The lock, not the GIL, is what protects your data.
Step 5 — Reproduce and fix a deadlock.
if __name__ == "__main__":
la, lb = threading.Lock(), threading.Lock()
def t1(): # grabs a then b
with la:
time.sleep(0.1)
with lb: pass
def t2(): # grabs b then a -> opposite order
with lb:
time.sleep(0.1)
with la: pass
a = threading.Thread(target=t1, daemon=True); b = threading.Thread(target=t2, daemon=True)
a.start(); b.start(); a.join(2.0); b.join(2.0)
print("deadlock!" if (a.is_alive() or b.is_alive()) else "ok")
la, lb = threading.Lock(), threading.Lock() # fresh locks
def ordered(): # EVERYONE grabs a then b
with la:
time.sleep(0.1)
with lb: pass
a = threading.Thread(target=ordered, daemon=True); b = threading.Thread(target=ordered, daemon=True)
a.start(); b.start(); a.join(2.0); b.join(2.0)
print("ordered:", "deadlock!" if (a.is_alive() or b.is_alive()) else "ok")
deadlock!
ordered: ok
What just happened: opposite lock order created a cycle — each thread holding what the other wanted — and both hung until the 2-second watchdog gave up. Making every thread acquire la before lb removed the cycle and both finished. (Note the fresh locks for the second test: the first test’s threads are still stuck holding the old locks, which would poison a re-test — a real gotcha.)
Step 6 — The unified API, both ways.
def square(x): return x * x
if __name__ == "__main__":
with ThreadPoolExecutor(4) as ex:
print("threads: ", list(ex.map(square, range(6))))
with ProcessPoolExecutor(4) as ex:
print("processes:", list(ex.map(square, range(6))))
threads: [0, 1, 4, 9, 16, 25]
processes: [0, 1, 4, 9, 16, 25]
What just happened: identical code, one class name different, running on threads then on processes. This is the everyday shape of the decision: write it once, measure both, ship the one that wins for your workload.
You have now watched threads do nothing for CPU work and everything for I/O, seen 74,000 updates disappear and a lock bring them back, hung a program on a deadlock and un-hung it with one ordering rule, and driven both backends from one API. That is the entire mental model, earned by measurement.
Common mistakes and troubleshooting
| Symptom / traceback | Cause | Fix |
|---|---|---|
| CPU-bound code, threads add no speedup (or slower) | The GIL — pure-Python bytecode never runs two threads at once | Use ProcessPoolExecutor; or move the hot loop into a C extension that releases the GIL |
RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase |
On spawn (macOS/Windows) the module re-imports; unguarded Pool/Process creation runs again → fork bomb |
Put all process-starting code under if __name__ == "__main__": |
_pickle.PicklingError: Can't pickle <function <lambda> ...> |
Passed a lambda to a Pool/ProcessPoolExecutor — it has no importable name |
Use a module-level def as the worker target |
AttributeError: Can't pickle local object 'f.<locals>.g' |
Passed a nested/local function to a process pool | Move the function to module level |
TypeError: cannot pickle '_io.TextIOWrapper' object (or socket, Lock) |
An open file / socket / DB connection / lock was passed as an argument | Pass the path/config, and open the resource inside the worker |
| Shared counter/list gives the wrong total across threads | Race condition — non-atomic read-modify-write on shared state | Guard it with a Lock (with lock:), or pass work through queue.Queue |
| Program hangs forever, no CPU use | Deadlock — two locks acquired in opposite order, or a Lock re-acquired by its owner |
Impose a global lock order; use RLock for re-entrant code; acquire(timeout=...) to fail fast |
RuntimeError: threads can only be started once |
Called .start() twice on one Thread object |
Create a new Thread per run (or use a pool) |
| Worker exception vanishes until much later | multiprocessing.Pool.apply_async holds the exception until .get() |
Call .get()/.result(); prefer concurrent.futures, which re-raises on .result() |
queue.Queue.get() blocks forever at the end |
No sentinel — workers wait for work that never comes | put(None) once per worker as a stop signal; or get(timeout=...) |
| Changes to a passed object don’t appear in the parent (multiprocessing) | Separate memory — the child mutated its own copy | Return the value, or use shared_memory / a Manager proxy |
| Daemon thread’s work is half-done / lost at exit | Daemon threads are killed when the main thread exits | Make it non-daemon and join() it, or use a pool with clean shutdown |
| Machine grinds to a halt with hundreds of threads | Context-switch thrash — too many threads for the work | Use a bounded pool (max_workers); for CPU work, ~cpu_count processes |
sys._is_gil_enabled → AttributeError |
You are on Python < 3.13 | It only exists on 3.13+; guard with hasattr(sys, "_is_gil_enabled") |
FileExistsError / leaked memory from SharedMemory |
Forgot to unlink(), or unlinked twice |
close() every handle; unlink() exactly once by the owner |
BrokenProcessPool |
A worker process crashed (segfault, os._exit, OOM-killed) |
Find the crashing task; catch it; don’t call os._exit in workers |
Three of these deserve extra words, because they cost the most hours.
1. “Threads didn’t make my CPU code faster.” This is the GIL, working as designed, and it is not a bug you can fix with better thread code. There is no lock to tune, no thread count that helps, no clever trick — pure-Python CPU work is serialised by the interpreter. The moment you see threads flat-line on a compute job, stop reaching for threads and switch to ProcessPoolExecutor. The one exception is when your compute is really inside a C extension (numpy, hashlib): then threads do parallelise it, because the extension released the GIL for you. Measure both; the numbers tell you which world you are in.
2. The missing if __name__ == "__main__" guard. On macOS and Windows this is not a style preference — it is the difference between a working program and a fork bomb that spawns thousands of processes before Python catches it. The cause is spawn: each worker starts a fresh interpreter that imports your module, and any unguarded top-level Pool() or Process().start() runs again in every child. The rule is mechanical: every line that starts a process goes under the guard. Develop on the spawn method even on Linux (where fork is the default) so this bites you in testing, not in production — and because fork-after-threads is itself unsafe.
3. The silent race condition. The scariest failure in this lesson produces no traceback — just a wrong number, intermittently. Because the GIL serialises many operations by accident, a race can hide for months and then surface under load, on a faster machine, or after a library upgrade changes timing. The defence is discipline, not luck: any mutable state touched by more than one thread needs a Lock, or must not be shared at all. The cleanest designs share nothing mutable — they pass immutable messages through a queue.Queue and keep each thread’s state private. When you can’t, wrap every read-modify-write in with lock: and keep the critical section tiny. And remember that free-threaded Python will expose every race the GIL is currently hiding, so this discipline is an investment, not a tax.
Cheat-sheet
| Syntax | What it does |
|---|---|
from concurrent.futures import ThreadPoolExecutor |
Pool of threads — for I/O-bound work |
from concurrent.futures import ProcessPoolExecutor |
Pool of processes — for CPU-bound work |
with ThreadPoolExecutor(max_workers=8) as ex: |
Auto-shutdown pool; default workers min(32, cpu+4) |
with ProcessPoolExecutor() as ex: |
Default workers = os.cpu_count() |
ex.map(fn, iterable) |
Results in input order; re-raises on iteration |
f = ex.submit(fn, *args) |
Schedule one call → a Future |
f.result(timeout=None) |
Block for the value; re-raises worker exceptions |
for f in as_completed(futures): |
Iterate futures in finish order |
import threading |
The low-level thread API |
t = threading.Thread(target=fn, args=()) |
Build a thread (does not start it) |
t.start() / t.join(timeout=None) |
Run it / wait for it |
threading.Thread(target=fn, daemon=True) |
Daemon: killed at exit, work may be lost |
lock = threading.Lock(); with lock: ... |
Mutual exclusion — one thread in the block |
threading.RLock() |
Re-entrant lock — same thread may re-acquire |
threading.Semaphore(n) |
Allow up to n threads at once |
threading.Event() — set()/wait() |
One flag, many waiters |
threading.Condition() — wait()/notify() |
Wait until state changes (producer/consumer) |
import queue; q = queue.Queue() |
Thread-safe work handoff — no manual locks |
q.put(x) / q.get() / q.task_done() / q.join() |
Produce / consume / ack / wait for all |
q.put(None) per worker |
The sentinel — tell workers to stop |
from multiprocessing import Process, Pool, Queue, Pipe |
The process API (mirrors threading) |
if __name__ == "__main__": |
MANDATORY around process-starting code (spawn) |
with Pool(4) as p: p.map(fn, xs) |
Process pool; fn must be a module-level def |
p.starmap(fn, [(a, b), ...]) |
Unpack tuples: fn(*args) |
r = p.apply_async(fn, args); r.get() |
Submit one; fetch (raises stored exception) |
from multiprocessing import shared_memory |
Raw shared bytes; close() + unlink() (⚠️ leaks) |
multiprocessing.set_start_method("spawn") |
Force spawn (safe, portable) |
sys.getswitchinterval() |
GIL hand-off interval — default 0.005 s |
sys._is_gil_enabled() |
3.13+: is the GIL on? (False on free-threaded build) |
| CPU-bound → processes. I/O-bound → threads/asyncio. | The whole decision, one line |
Interview and exam questions
Q: What is the GIL, in one sentence, and what is its precise consequence? A: The Global Interpreter Lock is a single mutex that a thread must hold to execute Python bytecode, so only one thread runs Python code at a time regardless of how many cores or threads you have. The precise consequence: threads give no speedup for CPU-bound Python work (they serialise on the lock) but real speedup for I/O-bound work (a thread releases the GIL while it waits, so other threads run). It is a CPython implementation detail — Jython and IronPython have no GIL.
Q: Why does CPython have a GIL if it prevents multi-core threading? A: To make reference counting thread-safe cheaply and to keep C extensions simple. Every object has a refcount touched by almost every operation; protecting each object with its own lock would mean millions of locks and would slow down the common single-threaded case. One interpreter-wide lock is far faster for single-threaded code and lets C-extension authors assume no concurrent interpreter access — an assumption baked into NumPy, pandas and thirty years of the ecosystem, which is exactly why removing it is so hard.
Q: I have a CPU-bound function. I run it in eight threads on an eight-core machine and it’s no faster. Why, and what do I do?
A: The GIL serialises the eight threads — only one runs bytecode at a time, so you get roughly 1.0x (measurably 0.99x in this lesson, slightly slower from switching overhead). No lock, thread count, or code change fixes it. Switch to ProcessPoolExecutor: separate processes have separate interpreters and separate GILs, so the same code runs in true parallel — this lesson measured ~4.5x on the identical workload. The one caveat: if the “CPU work” is actually inside a C extension that releases the GIL (numpy, hashlib), threads do parallelise it.
Q: The GIL makes counter += 1 safe across threads, right?
A: No — this is the most dangerous misconception in Python concurrency. The GIL protects the interpreter’s internal state (refcounts, internal structures), not your data. counter += 1 is three bytecodes — read, add, write — and the GIL can switch threads between them, so two threads can read the same value and one increment is lost. This lesson measured four threads losing 74,000 of 100,000 updates. You must guard shared mutable state with a Lock yourself; the GIL will not.
Q: Walk me through fixing a race condition on a shared counter.
A: Wrap the read-modify-write in a critical section so only one thread executes it at a time: create a threading.Lock() and use with lock: around counter = counter + 1. The lock forces the three steps to happen atomically with respect to other threads — one acquires, updates, releases; the next then proceeds. Keep the critical section as small as possible (lock only the shared mutation) to minimise contention, and always use with lock: rather than manual acquire()/release() so the lock is released even if the body raises. Better still, avoid shared mutable state entirely by passing work through a queue.Queue.
Q: Why is if __name__ == "__main__" mandatory with multiprocessing, and what happens without it?
A: On the spawn start method (the default on macOS and Windows) creating a worker launches a fresh interpreter that re-imports your module to find the worker function. If your pool/process creation is at module top level, unguarded, the child re-runs it and creates its own children, which re-import and create more — a runaway spawn. Python detects it and raises RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase, but only after thousands of failed launches. The guard ensures that top-level driver code runs only when the file is executed directly, not when a child imports it.
Q: What can’t you pass to a process pool, and why?
A: Anything that can’t be pickled, because arguments and results are serialised to cross the process boundary (processes share no memory). The classic offenders: lambdas and local/nested functions (no importable name → PicklingError / AttributeError: Can't pickle local object), and open files, sockets, database connections and locks (they wrap OS resources that can’t be recreated by unpickling). The fix is to use module-level functions as targets and to open resources inside the worker, passing only the picklable config (a path, a connection string).
Q: Compare ThreadPoolExecutor and ProcessPoolExecutor. When each?
A: Same API — submit, map, as_completed, futures that re-raise exceptions on .result() — differing in one word. ThreadPoolExecutor runs tasks in threads sharing one GIL: use it for I/O-bound work and for CPU work done in GIL-releasing C extensions; it is cheap (shared memory, fast start) but can’t parallelise pure-Python CPU. ProcessPoolExecutor runs tasks in separate processes with separate GILs: use it for pure-Python CPU-bound work; it gives real multi-core parallelism but costs process startup and pickling of every argument/result. Because the API is identical, write once, measure both, keep the winner.
Q: What is a deadlock, and how do you prevent it?
A: A deadlock is two (or more) threads each holding a lock the other needs, so all wait forever — no error, just a hung program. The classic cause is two locks acquired in opposite order. Deadlock needs all four Coffman conditions (mutual exclusion, hold-and-wait, no preemption, circular wait); breaking any one prevents it. The standard fix is to break circular wait by imposing a global lock ordering — every thread acquires locks in the same fixed order, so no cycle can form. Defensive backups: acquire(timeout=...) to fail instead of blocking forever, holding fewer locks, or replacing shared-lock designs with a queue.Queue.
Q: When would you choose asyncio over threads?
A: For I/O-bound work with very high concurrency — thousands to tens of thousands of simultaneous connections. Asyncio runs on a single thread with a cooperative event loop, so each task is a lightweight coroutine rather than an OS thread, using far less memory and avoiding lock hazards. Threads are simpler for a modest number of I/O tasks (dozens to hundreds) and don’t require an async rewrite. The trap in asyncio is that a single blocking call — a time.sleep, a CPU loop, a synchronous DB driver — freezes the entire event loop, so everything on the hot path must be non-blocking (await).
Q: What does PEP 703 change, and does it make locks unnecessary?
A: PEP 703 adds an experimental free-threaded build of CPython (3.13+, compiled --disable-gil) where the GIL is gone and pure-Python threads run in parallel on all cores — the CPU speedup you currently only get from processes, without pickling. It does not make locks unnecessary; it makes them more necessary, because today the GIL accidentally serialises some operations and hides races, and removing it exposes every unsynchronised shared write. The APIs are unchanged. It’s not the default because single-threaded code is slower on that build, C extensions must be rebuilt for it, and it’s still stabilising (3.14 made it supported but still opt-in).
Q (coding): This code sometimes prints the wrong total. Fix it.
import threading
total = 0
def add_all(nums):
global total
for n in nums:
total += n
threads = [threading.Thread(target=add_all, args=([1]*100_000,)) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(total) # sometimes < 800000
A: total += n is a non-atomic read-modify-write shared across threads — a race. Guard it with a lock:
import threading
total = 0
lock = threading.Lock()
def add_all(nums):
global total
for n in nums:
with lock:
total += n
Because this locks on every element, it is also slow. The better design accumulates a local subtotal (no sharing, no lock) and adds it once under the lock — or, cleanest, uses ProcessPoolExecutor/ThreadPoolExecutor and sums the returned results, sharing nothing:
def add_all(nums):
return sum(nums) # pure, no shared state
# total = sum(ex.map(add_all, chunks))
Q (coding): Why does this hang, and how do you fix it?
import threading, queue
q = queue.Queue()
def worker():
while True:
item = q.get()
print(item); q.task_done()
threading.Thread(target=worker, daemon=True).start()
for i in range(3): q.put(i)
q.join()
# ...program never ends cleanly
A: The worker loops forever on q.get(), which blocks once the queue empties, so the thread never exits — it only survives here because it is a daemon (a non-daemon worker would hang the whole program). The fix is a sentinel: send a stop signal and break on it.
def worker():
while True:
item = q.get()
if item is None: # sentinel
q.task_done(); break
print(item); q.task_done()
# after the real items:
q.put(None) # one sentinel per worker
Key takeaways
- The GIL is one mutex; only one thread runs Python bytecode at a time. It exists to make reference counting cheap and C extensions simple. Its precise consequence: pure-Python threads cannot use a second core. This is CPython behaviour, not a language rule, and not a bug you can code around.
- Threads: no speedup for CPU-bound work, huge speedup for I/O-bound. Measured on identical code: CPU-bound threads hit 0.99x (the GIL serialises them), I/O-bound threads hit 15.93x (waiting releases the GIL, so waits overlap). The one exception is CPU work inside a GIL-releasing C extension (numpy, hashlib), where threads do parallelise — measured 3.01x. Always measure both; the ratio tells you which world you’re in.
- Processes give real multi-core parallelism — for a price. Each process has its own interpreter and GIL, so pure-Python CPU work runs in parallel (measured ~4.5x on the same job threads couldn’t speed up). The costs: slow startup, tens of MB each, and everything must pickle (no lambdas, no open files, no shared objects). Give each process enough work to dwarf the overhead.
- The GIL protects the interpreter, not your data.
counter += 1across threads is a race — this lesson lost 74,000 of 100,000 updates with no error. Guard every shared read-modify-write with aLock(with lock:), keep critical sections tiny, and prefer passing work through aqueue.Queueto sharing mutable state at all. Free-threaded Python will expose every race the GIL currently hides. if __name__ == "__main__"is mandatory around process-starting code. On the spawn method (macOS/Windows default) each worker re-imports your module; unguarded pool creation becomes a fork bomb that raisesRuntimeErrorafter thousands of spawns. Every line that starts a process goes under the guard.- Deadlock is two locks in opposite order; fix it with a global lock ordering. Have every thread acquire locks in the same fixed sequence and no cycle can form. Use
RLockfor re-entrant code andacquire(timeout=...)as a defensive backstop. concurrent.futuresis one API for both — swap one word.ThreadPoolExecutorfor I/O,ProcessPoolExecutorfor CPU, identicalsubmit/map/as_completedcode, and futures that re-raise worker exceptions on.result(). Write once, measure both, keep the winner. The decision reduces to one question: CPU-bound → processes; I/O-bound → threads or asyncio.- Free-threaded CPython (PEP 703, 3.13+) is coming but opt-in. It removes the GIL so pure-Python threads parallelise, but single-threaded code is slower, C extensions must be rebuilt, and races become more exposed — so it is not the default. Write disciplined locking and process/thread code now and it stays correct on both builds.
This lesson gave you the one distinction the rest of Python concurrency hangs on — CPU-bound versus I/O-bound, processes versus threads — and the habit of measuring rather than guessing. The next time a job pins one core while seven sleep, you will know in a sentence whether to add threads, reach for processes, or leave it serial, and you will have the numbers to prove you chose right.