













In this article we are going to discuss Python Multiprocessing vs Threading.
You run the following code and watched five seconds of work finish in one:
import time
from concurrent.futures import ThreadPoolExecutor
def slow_task(n):
time.sleep(1) # pretend this is a download
return n * 2
with ThreadPoolExecutor(max_workers=5) as pool:
results = list(pool.map(slow_task, range(5))) # ~1 second, not 5
Threads look like magic. So the natural next thought is: "My number-crunching script is slow, I'll just throw threads at it too." Then you do, and nothing gets faster. This guide explains exactly why that happens, and shows you (with runnable code and real timings) which tool actually speeds up CPU-heavy work in Python.
Key Takeaways - Threads in standard CPython can't run Python computations in parallel because of the Global Interpreter Lock (GIL) — only one thread executes Python bytecode at a time (Python docs) - For CPU-bound work, use
multiprocessing/ProcessPoolExecutor: each process gets its own interpreter and its own GIL, so they run truly in parallel across cores. - For I/O-bound work (downloads, disk, database calls), threads are perfect, the GIL is released while a thread waits. - In my own 8-core benchmark, threads gave a 1.0× speedup on CPU work (nothing) but ~8× on I/O work; processes gave ~4.6× on the same CPU work. - Python 3.14 (Oct 2025) makes a free-threaded "no-GIL" build officially supported — but it's opt-in, not the default, somultiprocessingis still the beginner-safe answer today.
Before you pick a tool, classify your work. This single distinction decides everything that follows.
time.sleep(1) in the snippet above is a stand-in for exactly this kind of waiting.Quick self-test: If your slow code is waiting on something outside Python (a server, a disk, an API), it's I/O-bound. If it's a busy loop doing arithmetic or data processing with no
sleepand no network, it's CPU-bound.
Why does this matter? Because threading only helps when there's waiting to overlap. When the work is pure computation, there's no idle time to fill — and, as you'll see next, a lock inside Python stops threads from computing simultaneously anyway.
Here's the rule that trips up every beginner: standard CPython lets only one thread run Python code at a time. The mechanism enforcing that is the Global Interpreter Lock, or GIL.
Think of the GIL as a single "talking stick." No matter how many threads you start, a thread can only execute Python bytecode while it holds the stick, and there's exactly one stick. Threads take turns; they don't run at the same time. The GIL exists because CPython's memory management (reference counting) isn't thread-safe, and a single lock is the simplest way to keep it correct (Real Python: What Is the Python GIL?, retrieved 2026-09-15).
For I/O-bound work this is fine — a thread releases the GIL while it waits for a download or disk read, so other threads run during the wait. That's why the opening snippet works. But for CPU-bound work there is no waiting: every thread wants the stick constantly, they hand it back and forth, and your program runs at roughly single-threaded speed — sometimes even a little slower, because of the overhead of passing the stick around.
Don't take my word for it. Here's a CPU-bound benchmark you can paste and run. The code is summing squares, no sleeping, no I/O:
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def cpu_task(n):
total = 0
for i in range(n): # pure computation — the CPU never waits
total += i * i
return total
NUMBERS = [8_000_000] * 8 # 8 heavy chunks
def timed(label, fn):
start = time.perf_counter()
fn()
print(f"{label:<34} {time.perf_counter() - start:.2f}s")
if __name__ == "__main__":
timed("Serial (one at a time)", lambda: [cpu_task(n) for n in NUMBERS])
timed("ThreadPoolExecutor (8 threads)",
lambda: list(ThreadPoolExecutor(8).map(cpu_task, NUMBERS)))
timed("ProcessPoolExecutor (8 processes)",
lambda: list(ProcessPoolExecutor(8).map(cpu_task, NUMBERS)))
On my 8-core machine (Python 3.11), this prints almost the same result every run:
| Approach | Time | Speedup vs serial |
|---|---|---|
| Serial (one at a time) | 2.48s | 1.0× |
| ThreadPoolExecutor (8 threads) | 2.46s | 1.0× — no improvement |
| ProcessPoolExecutor (8 processes) | 0.54s | ~4.6× |
My finding: Eight threads made the CPU-bound task exactly as slow as running it one at a time. Eight processes cut it to roughly a fifth. The GIL is the whole reason for the gap.
If the problem is "one GIL per interpreter," the fix is simple: use more interpreters. That's what multiprocessing does. Instead of many threads sharing one interpreter (and one GIL), it starts several separate Python processes — each with its own interpreter, its own memory, and its own GIL. The operating system schedules them onto different CPU cores, and they genuinely run at the same time (Python concurrent.futures docs, retrieved 2026-09-15).
That real parallelism isn't free, though. Separate processes can't share Python objects directly, so Python has to pickle (serialize) every argument you send into a worker and every result it sends back, then ship the bytes across a pipe. Starting a process also costs more than starting a thread, and each process duplicates interpreter memory (Python⇒Speed: faster multiprocessing, retrieved 2026-09-15).
The practical consequences for a beginner:
The best part of concurrent.futures is that switching from threads to processes is almost a one-word change. Same map, same result handling — you swap the executor class:
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
# I/O-bound? Use threads.
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(download, urls))
# CPU-bound? Change ONE word: Thread -> Process.
with ProcessPoolExecutor(max_workers=8) as pool:
results = list(pool.map(cpu_task, numbers))
ou can also submit tasks individually and collect them as they finish with as_completed, which is handy when some tasks finish much sooner than others
from concurrent.futures import ProcessPoolExecutor, as_completed
with ProcessPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(cpu_task, n) for n in numbers]
for future in as_completed(futures):
print(future.result()) # prints in completion order, not submit order
Two beginner notes that save real debugging time:
if __name__ == "__main__":. Without it, each child process re-imports your script and tries to spawn its own children — an infinite fork bomb. This is the single most common multiprocessing mistake.max_workers yourself. Leave it out and ProcessPoolExecutor defaults sensibly. ThreadPoolExecutor defaults to min(32, os.cpu_count() + 4) — enough threads to overlap I/O without going overboard (Python docs, retrieved 2026-09-15).You may have heard that "Python is removing the GIL." That's real, and it's the biggest change to this topic in decades — but as a beginner in 2026, you should understand it without rushing to rewrite anything.
Here's the timeline. The core team accepted PEP 703 on 24 October 2023, agreeing to make the GIL optional through a gradual, reversible rollout (PEP 703, retrieved 2026-09-15). Python 3.13 (October 2024) shipped the first experimental free-threaded build. Then PEP 779 (accepted 16 June 2025) set the criteria for calling it "supported," and Python 3.14 (October 2025) promoted the free-threaded build to officially supported — but still optional, not the default (PEP 779, retrieved 2026-09-15).
Two facts make the free-threaded build genuinely usable now, where the early version wasn't. First, it ships as a separate interpreter with a t suffix — python3.14t — so your normal Python is untouched; you opt in explicitly (Python free-threading HOWTO, retrieved 2026-09-15). Second, the single-threaded overhead has collapsed: it was roughly 40% in 3.13 but is down to about 1–8% in 3.14 depending on platform (Python free-threading HOWTO, retrieved 2026-09-15).
What this means for you today:
multiprocessing. It works on the Python you already have, it's battle-tested, and it doesn't depend on every dependency being free-threading-ready.You rarely need to agonize over this. Match the workload to the tool:
| Your work is... | Best tool | Why |
|---|---|---|
| CPU-bound (math, parsing, image/video processing) | multiprocessing / ProcessPoolExecutor |
Separate processes dodge the GIL and use all cores |
| I/O-bound, a handful of tasks (downloads, files, DB) | threading / ThreadPoolExecutor |
GIL releases during waits; threads overlap the idle time |
| I/O-bound, thousands of tasks | asyncio |
Scales to huge numbers of waits with less memory than threads |
| CPU-bound and you control the environment | Free-threaded python3.14t + threads |
True thread parallelism without pickling — if your libs support it |
The mistakes to avoid:
Yes, and it's common in larger programs — for example, a pool of processes for the heavy computation, each using threads internally to overlap I/O. Start simple, though: get one working before you combine them.
asyncio is another way to handle I/O-bound work, built for programs that juggle thousands of simultaneous waits (like a web server) with far less overhead than thousands of threads. It does not help CPU-bound work and it runs on a single thread under the same GIL.
No. For CPU-bound work, speedup is capped at the number of physical cores; once every core is busy, extra processes just compete for the same cores and add overhead. For I/O-bound work you can often use more workers than cores, because most are waiting rather than computing.
Not yet. The free-threaded build is supported but optional in 3.14, isn't the default, and depends on your libraries being compatible (PEP 779, retrieved 2026-09-15). multiprocessing still works everywhere and remains the safe default for CPU-bound work today.
The confusion melts away once you ask one question: is your slow code waiting or computing?
asyncio at large scale). The GIL releases during waits, so threads overlap beautifully.multiprocessing / ProcessPoolExecutor. Separate processes each get their own GIL and run across all your cores.The sleep-based snippet that started this article was I/O-bound, which is exactly why threads made it fly. Swap in real computation and the GIL slams the door — that's your cue to reach for processes. And while the free-threaded build in Python 3.14 is quietly rewriting these rules, multiprocessing remains the reliable, beginner-safe choice in 2026.
Grab the benchmark script above, run it on your own machine, and watch the numbers prove it.
About the author. Written by Aleksandro Matejic. The benchmark numbers in this article weren't estimated — they were measured first-hand on an 8-core machine running Python 3.11, using the exact script shown above. Copy it, run it on your own hardware, and you should see the same pattern: threads flat on CPU-bound work, processes roughly matching your core count.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。