Concurrency: Threads and Processes 🧵
Python's threading has a famous asterisk. Here is what it actually is, why it exists, and how to pick the right tool without folklore.
Two different problems
| Kind of work | What it is | Called |
|---|---|---|
| Downloading 100 web pages | Mostly waiting for something else | I/O bound |
| Resizing 100 photos | Mostly using the CPU | CPU bound |
Nearly all confusion about Python concurrency comes from not asking this question first. The answer determines the tool, and using the wrong one makes your program slower, not faster.
The GIL, without the mythology
CPython has a Global Interpreter Lock: a single lock that means only one thread executes Python bytecode at a time. It exists because it makes the interpreter simpler and single-threaded code faster, and because CPython's memory management is not thread-safe without it.
The consequences, stated precisely:
- Threads do not speed up pure-Python CPU work. Four threads doing arithmetic take about as long as one, plus overhead.
- Threads absolutely do speed up waiting. The lock is released while a thread waits on the network, a disk or a lock, so a hundred threads can wait at the same time.
- Well-written C extensions release the lock too. NumPy's heavy numeric work is genuinely parallel across threads.
- It is not part of the language. Jython and IronPython have no GIL, and Python 3.13 shipped an experimental free-threaded build that removes it. The rules above are about CPython today.
PEP 703, accepted in 2023, lays out a path to making CPython work without the GIL, and 3.13 shipped it as an optional build. It is not the default: removing the lock costs single-threaded performance and requires every C extension in the ecosystem to be audited.
So 'Python cannot do threads' is folklore, and 'the GIL is about to disappear' is premature. Both statements are the kind of thing people repeat without checking.
Threads, where they shine
import time
from concurrent.futures import ThreadPoolExecutor
def fetch(page):
"""Pretend to download something: mostly waiting."""
time.sleep(0.1)
return f"page {page}: 200 OK"
start = time.perf_counter()
sequential = [fetch(n) for n in range(8)]
sequential_time = time.perf_counter() - start
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
threaded = list(pool.map(fetch, range(8)))
threaded_time = time.perf_counter() - start
print(sequential[0])
print(f"sequential took about 0.8s: {0.7 < sequential_time < 1.2}")
print(f"threaded took about 0.1s: {threaded_time < 0.4}")
print(f"same results: {sequential == threaded}")
page 0: 200 OK
sequential took about 0.8s: True
threaded took about 0.1s: True
same results: True
Eight tasks that each wait a tenth of a second: eight tenths sequentially, one tenth in parallel. The GIL is irrelevant here because none of these threads wants the CPU. This is most real-world concurrency: talking to APIs, databases and files.
Processes, for actual CPU work
from concurrent.futures import ProcessPoolExecutor
def count_primes(limit):
"""Deliberately CPU-heavy."""
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
count += 1
return count
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(count_primes, [20000, 20000, 20000, 20000]))
print(results)
Each process is a separate Python interpreter with its own GIL, so they genuinely run on different cores. The costs are real: starting a process is slow (tens of milliseconds), and every argument and result must be pickled and copied between processes. Use them for chunky work, never for tiny tasks.
On Windows and macOS, child processes re-import your module to find the function they are running. Without if __name__ == "__main__":, every child re-runs your top-level code and spawns more children. It is a fork bomb written by accident, and everybody does it once.
Choosing
| Your work | Use | Why |
|---|---|---|
| Waiting on network or disk, tens of tasks | ThreadPoolExecutor | Simple, shares memory, waiting is free |
| Waiting on network, thousands of tasks | asyncio (Lesson 40) | Threads cost about 8MB of stack each; coroutines cost bytes |
| Heavy CPU work on many cores | ProcessPoolExecutor | Real parallelism, at the cost of copying |
| Heavy numeric arrays | NumPy, and often nothing else | It already releases the GIL and uses vector instructions |
| It is fast enough already | Nothing | Concurrency is a bug multiplier. Earn it first |
The dangerous part: shared state
import threading
counter = 0
def increment_unsafe():
global counter
for _ in range(100_000):
counter += 1 # read, add, write: three steps, interruptible
threads = [threading.Thread(target=increment_unsafe) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"expected 400000, got {counter}")
print(f"correct? {counter == 400_000}")
That program is a genuine race condition: counter += 1 is three operations,
and a thread can be interrupted between them, losing an update. On modern CPython you
will often get the right answer anyway, which is worse than always getting it wrong,
because the bug only appears under load, in production, at 3am.
import threading
counter = 0
lock = threading.Lock()
def increment_safe():
global counter
for _ in range(100_000):
with lock: # only one thread inside at a time
counter += 1
threads = [threading.Thread(target=increment_safe) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"expected 400000, got {counter}")
expected 400000, got 400000
The better answer: do not share
from concurrent.futures import ThreadPoolExecutor
def work(n):
"""Pure function: takes a value, returns a value, shares nothing."""
return n * n
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(work, range(6)))
print(results)
print(sum(results))
[0, 1, 4, 9, 16, 25]
55
No lock, no race, no possibility of one. Threads that communicate by returning values
rather than by mutating shared state are dramatically easier to get right. When you must
share, use queue.Queue, which is thread-safe by design, rather than a list
and a prayer.
Futures, when tasks finish at different times
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch(page):
time.sleep(0.05 * (4 - page)) # later pages finish first
return f"page {page}"
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {pool.submit(fetch, n): n for n in range(4)}
for future in as_completed(futures):
print("finished:", future.result())
finished: page 3
finished: page 2
finished: page 1
finished: page 0
submit returns a Future: a promise of a result.
as_completed yields them in the order they finish, so you can start
processing the fast ones without waiting for the slow one. Exceptions are re-raised when
you call .result(), which is a considerate design: nothing is swallowed.
I/O bound or CPU bound?
For each, name the tool.
- Download 500 images from an API.
- Resize those 500 images.
- Read 10,000 small files and count the lines.
- Train a machine learning model.
- Poll three sensors every second for a day.
Reveal solution
- I/O bound. Threads, or asyncio at this volume.
- CPU bound. Processes, or a library like Pillow that releases the GIL.
- Mostly I/O bound, though at 10,000 files the per-file overhead starts to matter. Threads, and measure.
- CPU bound, and you should not be writing the concurrency yourself: PyTorch already uses every core and your GPU.
- I/O bound and mostly idle. Threads are plenty; asyncio is elegant. The real constraint is the sensors, not Python.
Fix the race
This should collect results from four threads. It sometimes loses some. Fix it two ways.
import threading
results = []
def work(n):
results.append(n * n)
threads = [threading.Thread(target=work, args=(i,)) for i in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()Reveal solution
Fix one, a lock:
import threading
results = []
lock = threading.Lock()
def work(n):
value = n * n
with lock:
results.append(value)
threads = [threading.Thread(target=work, args=(i,)) for i in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(sorted(results))
[0, 1, 4, 9]Fix two, which is better: stop sharing.
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda n: n * n, range(4)))
print(results)
[0, 1, 4, 9]The second version has no shared state, needs no lock, keeps the results in order, and is four lines. As a bonus, list.append is actually atomic in CPython today, so version one was probably safe by accident, which is exactly the kind of thing you should never rely on.
Measure before you optimise
Write a small benchmark comparing sequential and threaded execution of a waiting task, and report the speedup honestly.
Reveal solution
import time
from concurrent.futures import ThreadPoolExecutor
def slow_task(n):
time.sleep(0.05)
return n
def timed(label, func):
start = time.perf_counter()
result = func()
elapsed = time.perf_counter() - start
print(f"{label:12} {elapsed:.2f}s")
return elapsed, result
sequential, _ = timed("sequential", lambda: [slow_task(n) for n in range(10)])
def threaded_run():
with ThreadPoolExecutor(max_workers=10) as pool:
return list(pool.map(slow_task, range(10)))
threaded, _ = timed("threaded", threaded_run)
print(f"speedup: {sequential / threaded:.1f}x")
print(f"worth it: {sequential / threaded > 2}")This one has no run button because the exact timings depend on the machine, and a lesson should not promise numbers it cannot prove. Run it locally: you should see roughly 0.5s sequential and 0.05s threaded, close to a tenfold speedup. Then change time.sleep to a CPU-heavy loop and watch the speedup vanish. That experiment teaches the GIL better than any explanation.