What you'll learn
Quick Answer
Threads do not speed up CPU-bound Python because the GIL allows only one thread to execute bytecode at a time. Use multiprocessing for CPU work and threads for waiting on input and output.
The measurement
Summing two million square roots, four times over, three ways:
import time, math
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def work(n):
return sum(math.sqrt(i) for i in range(n))
if __name__ == "__main__":
tasks = [2_000_000] * 4
t0 = time.perf_counter(); [work(n) for n in tasks]
print(f"sequential : {time.perf_counter()-t0:.2f}s")
t0 = time.perf_counter()
with ThreadPoolExecutor(4) as ex: list(ex.map(work, tasks))
print(f"4 threads : {time.perf_counter()-t0:.2f}s")
t0 = time.perf_counter()
with ProcessPoolExecutor(4) as ex: list(ex.map(work, tasks))
print(f"4 processes: {time.perf_counter()-t0:.2f}s")
sequential : 2.88s
4 threads : 2.96s
4 processes: 0.62s
Threads were marginally slower than doing nothing special — the overhead of switching between them, with no parallelism gained. Processes were about four and a half times faster on an eight-core machine.
Why threads did nothing
CPython has a global interpreter lock. Only one thread may execute Python bytecode at any instant, so four threads doing arithmetic take turns rather than running together.
The lock exists because CPython's memory management is not thread-safe, and a single coarse lock is far simpler and faster for single-threaded code than locking every object individually.
The crucial detail is that the GIL is released during input and output. A thread waiting on a network response or a disk read is not executing bytecode, so others run meanwhile. That is why threads genuinely help for downloading fifty URLs and do nothing for computing fifty checksums.
Processes escape it entirely because each has its own interpreter and its own GIL. The cost is that they cannot share memory directly.
Choosing between them
- CPU-bound work — image processing, numeric computation, parsing large files, cryptography. Use
ProcessPoolExecutor. - I/O-bound work — HTTP requests, database queries, file reads. Use
ThreadPoolExecutor, or asyncio for very large numbers of concurrent waits.
The quick test: if the task would be faster on a faster CPU, it is CPU-bound. If it spends its time waiting for something else, it is I/O-bound.
One important exception: libraries such as NumPy release the GIL inside their C code, so numeric work in NumPy can genuinely benefit from threads. If your heavy lifting is already in a compiled library, measure before assuming you need processes.
The practical gotchas
The __main__ guard is mandatory on Windows and macOS. Those platforms start child processes by importing your module, so without the guard the child re-runs your top-level code and spawns more children. The failure is dramatic and confusing, and on Windows it also means you cannot run a multiprocessing example straight from an interactive prompt.
if __name__ == "__main__":
# multiprocessing code goes here
Arguments and results are pickled. Everything crossing a process boundary must be serialisable, so lambdas, open file handles and database connections cannot be passed. Sending large objects is also genuinely slow — the transfer can cost more than the computation saves.
Processes do not share memory. Each has its own copy of everything, so a global modified in a worker is invisible to the parent. Return values instead, or use the explicit shared-memory types.
More processes than cores does not help for CPU work. Default the pool to the core count.
The practical pattern
concurrent.futures is the interface to use. It gives one API for both, so switching between threads and processes is a single word change:
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as ex:
results = list(ex.map(process_image, filenames))
That default pool size is the CPU count, the context manager waits for completion and shuts down cleanly, and map returns results in input order.
Before reaching for either, check whether the work is worth parallelising at all. Process startup costs real time, so parallelising a task that takes 50 milliseconds makes it slower. Measure the sequential version first — the numbers at the top of this article exist because they were measured, not assumed.
See process vs thread for the operating-system level distinction.
