Every Python developer eventually hits a wall where their code needs to do more than one thing “at once,” and then discovers that Python offers not one but three completely different answers: threading, multiprocessing, and asyncio. They all live in the standard library, they all let you write code that overlaps in time, and their APIs even look superficially similar. But they solve different problems, and picking the wrong one doesn’t just underperform, it can silently fail to help at all.

I’ve seen teams reach for multiprocessing to speed up a network-bound scraper (wrong tool, huge memory overhead for no benefit) and reach for threading to speed up a CPU-bound image resizer (wrong tool, the GIL means it barely beats single-threaded). The fix in both cases wasn’t more code, it was picking the model that actually matches the bottleneck.

The core question: what’s actually blocking you?

Before touching any of these three tools, answer one question: is your program waiting on something outside the CPU, or is it burning CPU cycles?

  • I/O-bound: waiting on a network response, a disk read, a database query, a subprocess. The CPU is idle almost the whole time, it’s just sitting there waiting.
  • CPU-bound: crunching numbers, parsing large files, resizing images, running a model. The CPU is pegged the whole time.

This single distinction determines which tool actually helps. Get it backwards and you’ll add complexity for zero speedup.

Why the GIL matters here

The elephant in the room is the Global Interpreter Lock (GIL). In the standard CPython implementation, only one thread can execute Python bytecode at a time, no matter how many threads you spin up. This is true even on an 8-core machine.

This one fact explains almost everything about why these three tools exist and behave so differently:

  • threading gives you real OS threads, but they still take turns holding the GIL. Threads do release the GIL while waiting on I/O (a network call, a file read), so threading is genuinely useful for I/O-bound work. But threads competing for CPU-bound work don’t get real parallelism, they get expensive time-slicing.
  • multiprocessing sidesteps the GIL entirely by using separate OS processes, each with its own Python interpreter and its own GIL. That’s real parallelism across CPU cores, at the cost of heavier startup, more memory, and the need to serialize data (pickle) to move it between processes.
  • asyncio doesn’t use OS threads or processes at all for the concurrency itself. It runs everything on a single thread with a single event loop, and achieves concurrency by cooperatively switching between tasks whenever one of them hits an await on something that isn’t ready yet. It never fights the GIL because there’s nothing to fight, only one thing is ever “running” at a time, they’re just very good at taking turns during I/O waits.

(As of recent CPython versions there’s ongoing work on a no-GIL build (PEP 703), but for most people running stock Python today, the GIL is still the operating reality. Don’t plan around its absence yet.)

threading: good for I/O, not for CPU

import threading
import requests

urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

results = []

def fetch(url):
    response = requests.get(url)
    results.append(response.status_code)

threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]

for t in threads:
    t.start()
for t in threads:
    t.join()

print(results)  # takes ~1 second total, not ~3

Each requests.get call blocks waiting on the network, and while it’s blocked, that thread releases the GIL so another thread can run. Three requests that would take 3 seconds sequentially finish in about 1 second running concurrently on threads.

Now watch the same pattern fail to help on CPU-bound work:

import threading
import time

def cpu_heavy(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

start = time.perf_counter()
threads = [threading.Thread(target=cpu_heavy, args=(20_000_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"threaded: {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
for _ in range(4):
    cpu_heavy(20_000_000)
print(f"sequential: {time.perf_counter() - start:.2f}s")

Run this and the “threaded” version is not meaningfully faster than the sequential one, sometimes slightly slower thanks to thread-switching overhead. The four threads are all fighting over the same GIL, taking turns instead of running in parallel. This is the trap: threading looks like it should parallelize CPU work, and on almost every other mainstream language it would, but Python’s GIL makes that intuition wrong.

Threading also brings the usual hazards of shared-memory concurrency: race conditions on shared state, and the need for locks (threading.Lock) to protect anything mutable that multiple threads touch.

multiprocessing: real parallelism for CPU-bound work

from multiprocessing import Pool
import time

def cpu_heavy(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

if __name__ == "__main__":
    start = time.perf_counter()
    with Pool(processes=4) as pool:
        results = pool.map(cpu_heavy, [20_000_000] * 4)
    print(f"multiprocessing: {time.perf_counter() - start:.2f}s")

On a machine with 4+ cores, this genuinely runs close to 4x faster than the sequential version, because each process has its own interpreter and its own GIL, so they truly run in parallel on separate cores.

The cost shows up in a few places:

  • Startup overhead: spawning a process is much heavier than spawning a thread, don’t use multiprocessing for tiny, frequent tasks, the overhead can dwarf the work itself.
  • Memory: each process gets its own copy of the Python interpreter and (depending on platform and start method) potentially a copy of your data. This adds up fast for large datasets.
  • Serialization: arguments and return values passed between processes get pickled and unpickled. Not everything is picklable (open file handles, database connections, lambdas), and large objects mean real serialization cost.
  • The if __name__ == "__main__": guard is mandatory on platforms that use the spawn start method (the default on macOS and Windows), because child processes re-import your module from scratch. Skip the guard and you can end up recursively spawning processes.

If your workers need to share state, you don’t get free shared memory like with threads, you need explicit tools: multiprocessing.Value, multiprocessing.Array, multiprocessing.Manager, or just design around passing data in and results out rather than mutating something in common.

asyncio: massive concurrency for I/O, on one thread

import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as response:
        return response.status

async def main():
    urls = ["https://httpbin.org/delay/1"] * 100
    async with aiohttp.ClientSession() as session:
        results = await asyncio.gather(*(fetch(session, url) for url in urls))
    return results

results = asyncio.run(main())

This fetches 100 URLs, each with a 1-second delay, in about 1 second total, not 100. That’s a scale of concurrency threads genuinely struggle with: spinning up 100 OS threads is expensive (each one costs real memory for its stack, plus OS scheduling overhead), but 100 asyncio tasks are cheap, they’re just Python objects managed by the event loop, not OS-level constructs.

The catch: everything in the call chain has to be async-aware. A single blocking call, like using requests instead of aiohttp inside an async function, blocks the entire event loop, not just that one task, because there’s only one thread running everything. This is the most common asyncio mistake: mixing in a synchronous, blocking library and wondering why concurrency disappeared.

# WRONG: this blocks the whole event loop for every request
async def fetch_bad(url):
    import requests
    return requests.get(url).status_code  # synchronous, blocks everything

# RIGHT: use an async-native client, or push blocking calls to a thread
async def fetch_good(url):
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, requests.get, url)

asyncio also doesn’t help CPU-bound work at all, a while True: pass inside a coroutine (without an await) freezes the entire event loop, since nothing can interleave with it. Async is for waiting efficiently, not computing faster.

Real-world pattern: mixing models when the workload is mixed

Real programs are rarely purely one or the other. A common and very effective pattern is asyncio for the I/O-bound orchestration, with CPU-bound chunks offloaded to a process pool so they don’t block the event loop:

import asyncio
from concurrent.futures import ProcessPoolExecutor

def resize_image(path: str) -> str:
    # CPU-bound work, pretend this uses Pillow
    ...
    return f"{path}.resized"

async def handle_upload(path: str, pool: ProcessPoolExecutor):
    loop = asyncio.get_running_loop()
    # offload the CPU-bound part so the event loop stays free
    resized_path = await loop.run_in_executor(pool, resize_image, path)
    return resized_path

async def main(paths: list[str]):
    with ProcessPoolExecutor() as pool:
        results = await asyncio.gather(*(handle_upload(p, pool) for p in paths))
    return results

asyncio.run(main(["a.jpg", "b.jpg", "c.jpg"]))

Here the async layer handles many concurrent uploads cheaply (accepting requests, reading from disk, writing responses), while the actual CPU-heavy resizing runs in worker processes that get real parallelism. Neither tool alone does the whole job well; together they cover both bottlenecks.

Real-world pattern: a quick decision framework

When I’m staring at a slow function and deciding what to reach for, I walk through this:

def pick_concurrency_model(bottleneck: str, scale: str) -> str:
    """
    bottleneck: "io" or "cpu"
    scale: "few" (dozens) or "many" (hundreds to thousands)
    """
    if bottleneck == "cpu":
        return "multiprocessing (or a subprocess/native extension, e.g. NumPy)"
    if bottleneck == "io" and scale == "many":
        return "asyncio"
    if bottleneck == "io" and scale == "few":
        return "threading (simpler than asyncio if you don't need huge scale)"
    return "you probably don't need concurrency yet, profile first"

A few notes behind the logic:

  • For a handful of I/O-bound tasks, plain threading is often simpler to reason about than rewriting everything with async/await, especially if you’re calling into libraries that aren’t async-native.
  • For thousands of concurrent I/O operations (a web server handling many simultaneous connections, a scraper hitting hundreds of endpoints), asyncio scales in a way threads can’t, thread-per-connection falls over long before task-per-connection does.
  • For CPU-bound work, don’t reach for threading or asyncio at all, they can’t give you parallelism on the GIL-bound interpreter. Go straight to multiprocessing, or better yet, see if a C-accelerated library (NumPy, Pillow, pandas) already does the heavy lifting in native code outside the GIL’s reach.
  • Always profile before adding concurrency of any kind. It’s easy to assume a slow function is I/O-bound when it’s actually spending its time on something CPU-heavy like JSON parsing or regex matching, and vice versa.

Summary

Python’s three concurrency tools aren’t interchangeable, they target different bottlenecks:

  • threading: real OS threads sharing one GIL. Good for a modest number of I/O-bound tasks. Doesn’t parallelize CPU-bound work because of the GIL. Watch out for shared-state races.
  • multiprocessing: separate processes, each with its own interpreter and GIL. The only one of the three that gives true parallelism for CPU-bound work. Costs more in startup time, memory, and serialization overhead.
  • asyncio: single-threaded cooperative concurrency built around await. Scales to huge numbers of I/O-bound tasks cheaply, but requires async-native libraries end to end, and does nothing for CPU-bound code.
  • The question that decides which one to reach for is always the same: is the code waiting on I/O, or is it burning CPU? Answer that first, then pick the tool, not the other way around.
  • Real systems often combine them, asyncio for orchestration and I/O, multiprocessing for the CPU-heavy chunks that would otherwise stall the event loop.