Only registred users can make comments
Aleksandro Matejic

Python Multiprocessing vs Threading: Which One Actually Speeds Up CPU-Bound Work?

CPU Multiprocessing vs Threading  Photo by Thorium on Unsplash

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, so multiprocessing is still the beginner-safe answer today.

The One Question That Decides Everything: Is Your Work CPU-Bound or I/O-Bound?

Before you pick a tool, classify your work. This single distinction decides everything that follows.

  • I/O-bound work spends most of its time waiting. Example for a network response, a file to load, a database query. Your CPU is idle during the wait. The time.sleep(1) in the snippet above is a stand-in for exactly this kind of waiting.
  • CPU-bound work spends most of its time computing like parsing, hashing, resizing images, math in a loop. The CPU is pinned at 100% the whole time. Nothing is waiting; there's simply a lot to calculate.

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 sleep and 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.

Why Threads Don't Speed Up CPU-Bound Work (The GIL, Explained Simply)

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.

How Multiprocessing Gets Around the GIL

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:

  • Multiprocessing wins when the computation per task is large relative to the cost of shipping data in and out. Summing eight million squares? Easily worth it.
  • It can lose on tiny tasks, where pickling and process startup dominate — sometimes making it slower than a plain loop.
  • Keep the data crossing the boundary small. Pass a filename instead of a giant list; return a number instead of a huge object.

Side-by-Side Code: The Same Task, Three Ways

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:

  1. Guard your entry point. On macOS and Windows, code that starts processes must sit under 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.
  2. You don't have to pick 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).

What About Python 3.13 and 3.14 and the "No-GIL" Free-Threaded Build?

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 suffixpython3.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:

  • On the standard build (what you get by default), the GIL is still there. Threads still won't parallelize CPU-bound Python. Nothing in this guide changes.
  • On the free-threaded build, threads can finally run CPU-bound Python across cores — but you must install it deliberately, accept a small single-threaded slowdown, and check that your C-extension libraries support it (some re-enable the GIL automatically if they aren't marked thread-safe).
  • For learning and for most projects in 2026, reach for 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.

A Simple Decision Rule (When to Use Which)

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:

  • Using threads for CPU-bound work and expecting a speedup. You'll get none.
  • Using processes for tiny tasks. Pickling and startup overhead can make them slower than a plain loop — batch small jobs into bigger chunks.
  • Assuming more workers is always faster. You're capped by physical cores; typical machines have around 4–8 (Club386, reporting PassMark data, retrieved 2026-09-15). Beyond that, extra workers just add overhead.

Frequently Asked Questions

Can I use threading and multiprocessing together?

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.

Where does asyncio fit in?

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.

Does adding more workers always make things faster?

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.

If Python 3.14 can remove the GIL, is multiprocessing obsolete?

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.

Conclusion

The confusion melts away once you ask one question: is your slow code waiting or computing?

  • Waiting (I/O-bound) → threads (or asyncio at large scale). The GIL releases during waits, so threads overlap beautifully.
  • Computing (CPU-bound)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.

 

Comments 0

No comments yet. Be the first.