Benchmark comparisons: threads or processes?
Whether threads or processes help depends on the kind of work. These numbers were measured on real CPython; you analyse them to learn the rules that decide.
Simulated: this runs against results recorded on real Python.
- Compute speed-up and efficiency from measured timings
- Explain why threads help waiting work and processes help computing work on standard CPython
- Recognise when the overhead of processes outweighs the gain
The course authors ran one benchmark on CPython 3.14.3 on an Apple M3 Pro (12 cores), using concurrent.futures with 8 workers and taking the best of 3 runs. Three workloads:
- io: 40 jobs that each wait 0.05 s (
time.sleep, standing in for a network request); - cpu: 8 jobs that each add up 2,000,000 squares in pure Python;
- tiny: 2,000 jobs that each add up only 2,000 squares.
These are measurements from that machine, given to you as data: this page can't run threads or processes, and timings on your own computer will differ. The pattern is what generalises.
Run each example to see what it prints. Running examples doesn't count toward progress.
measured = {
"io": {"serial": 2.134, "threads": 0.271, "processes": 0.363},
"cpu": {"serial": 0.614, "threads": 0.583, "processes": 0.179},
"tiny": {"serial": 0.112, "threads": 0.118, "processes": 0.196},
}
for workload, seconds in measured.items():
threads = seconds["serial"] / seconds["threads"]
processes = seconds["serial"] / seconds["processes"]
print(f"{workload:5} threads x{threads:.1f} processes x{processes:.1f}")Efficiency is speed-up ÷ number of workers: how much of the extra hardware you actually used. The cpu workload's ×3.4 with 8 processes is about 43%: start-up, pickling and the fact that 8 jobs don't always split perfectly over the cores all take a share. Measurements like these, not intuition, should decide whether parallel code is worth its complexity.
measured = {
"io": {"serial": 2.134, "threads": 0.271, "processes": 0.363},
"cpu": {"serial": 0.614, "threads": 0.583, "processes": 0.179},
"tiny": {"serial": 0.112, "threads": 0.118, "processes": 0.196},
}
workers = 8
for workload, seconds in measured.items():
best_mode = min(seconds, key=seconds.get)
efficiency = seconds["serial"] / seconds[best_mode] / workers
print(workload, best_mode, f"{efficiency:.0%}")Rules from the data (standard CPython with a GIL): waiting-bound work → threads (or asyncio, the Async Python module); CPU-bound pure Python in big chunks → processes; small jobs → batch them into bigger chunks or stay serial. Free-threaded builds (3.13t+) and libraries that release the GIL (NumPy, compression, hashing) change the cpu row, which is exactly why you measure on your own setup. Mistakes to avoid: expecting ×8 from 8 workers; benchmarking tiny inputs; and comparing numbers from different machines.
- Speed-up = serial ÷ parallel time; efficiency = speed-up ÷ workers.
- Measured on CPython 3.14: threads ×7.9 on waiting, ×1.1 on computing; processes ×3.4 on computing, slower on tiny jobs.
- Measure on your own workload before adopting parallel code.
- Next module: asyncio, a single thread that overlaps waiting without any threads at all.