Home DevOps & Cloud Security Software Engineering AI & Machine Learning Web Development Developer Tools Programming Languages Databases Architecture & Systems Design Emerging Tech About
Programming Languages

Python asyncio vs threading vs multiprocessing

NanoTech Insight
NanoTech Insight
2026-10-23
How this article was made: drafted with AI assistance and grounded in the sources linked at the end of the article. Spotted an error? Let us know.
Python programming language logo

Python has three built-in concurrency tools β€” asyncio, threading, and multiprocessing β€” and they solve different problems. For I/O-bound work (network calls, file reads, database queries), both asyncio and threading give roughly a 10x speedup over serial code. For CPU-bound work, only multiprocessing helps, because Python's Global Interpreter Lock (GIL) prevents threads from running Python bytecode in parallel.

The core difference

The three tools operate at different levels:

Benchmark: I/O-bound work

We ran 10 tasks that each sleep for 200ms (simulating a network call or file read):

import asyncio
import threading
import time

N = 10
SLEEP = 0.2  # 200ms per task

# serial β€” one after another
def serial():
    t0 = time.perf_counter()
    for _ in range(N):
        time.sleep(SLEEP)
    return time.perf_counter() - t0

# threading β€” all tasks run concurrently in OS threads
def threaded():
    results = [None] * N
    t0 = time.perf_counter()
    threads = [threading.Thread(target=lambda i=i: time.sleep(SLEEP)) for i in range(N)]
    for t in threads: t.start()
    for t in threads: t.join()
    return time.perf_counter() - t0

# asyncio β€” all tasks run concurrently on the event loop
async def async_main():
    t0 = time.perf_counter()
    await asyncio.gather(*[asyncio.sleep(SLEEP) for _ in range(N)])
    return time.perf_counter() - t0

Results on Python 3.12.3 (Ubuntu 24.04):

I/O-bound simulation: 10 tasks Γ— 200ms sleep each

serial...    2.002s
threading... 0.202s  (9.9x faster than serial)
asyncio...   0.201s  (9.9x faster than serial)

Both threading and asyncio complete in roughly the time of one task instead of ten. That's the whole point: while one task is waiting for I/O, the others run.

Benchmark: CPU-bound work

Now a task that actually uses the CPU β€” counting to 5 million:

import asyncio
import threading
import multiprocessing
import time

def cpu_task(n=5_000_000):
    x = 0
    for i in range(n):
        x += i
    return x

N = 4  # four parallel tasks
CPU-bound benchmark: 4 tasks counting to 5,000,000

serial...         2.124s
threading...      2.719s  (GIL prevents true parallelism β€” actually slower)
asyncio...        2.545s  (blocks the event loop β€” no parallelism)
multiprocessing.. 2.575s  (separate processes, bypasses GIL)

Note: This benchmark ran on a single-core machine (1 CPU). That's why multiprocessing shows no improvement over serial β€” there's only one physical core to schedule on. On a 4-core machine, multiprocessing typically completes in roughly 1/N_cores of the serial time.

The important takeaways here are threading and asyncio: both are slower than serial for CPU work. Threading adds locking overhead on top of the GIL; asyncio blocks the entire event loop while a CPU-bound coroutine runs.

Why threading doesn't help for CPU work: the GIL

Python's Global Interpreter Lock ensures only one thread executes Python bytecode at a time. Here's a concrete demonstration:

import threading
import time

def count(n):
    x = 0
    for i in range(n):
        x += i

N = 10_000_000

# Two threads, each counting to N
t0 = time.perf_counter()
t1 = threading.Thread(target=count, args=(N,))
t2 = threading.Thread(target=count, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"2 threads: {time.perf_counter() - t0:.3f}s")

# One thread doing double the work
t0 = time.perf_counter()
count(2 * N)
print(f"1 thread:  {time.perf_counter() - t0:.3f}s")
2 threads each counting to 10,000,000:  2.686s
1 thread counting to 20,000,000:        2.931s

Two threads doing half the work each take nearly the same time as one thread doing all of it. They can't overlap. (The slight variation is scheduling noise.)

The GIL is released during I/O waits β€” that's why threading works well there. It is not released during Python bytecode execution.

Using asyncio with blocking libraries

A common problem: you're in an async codebase but need to call a library that's synchronous (e.g., requests, sqlite3, a blocking database driver). Calling it directly blocks the event loop and freezes all other coroutines.

The fix is loop.run_in_executor(), which offloads the blocking call to a thread pool:

import asyncio
import time

def blocking_io(task_id):
    """Simulates a blocking call β€” e.g. requests.get()"""
    time.sleep(0.2)
    return f"task {task_id} done"

async def main():
    loop = asyncio.get_event_loop()
    t0 = time.perf_counter()
    tasks = [
        loop.run_in_executor(None, blocking_io, i)
        for i in range(10)
    ]
    results = await asyncio.gather(*tasks)
    print(f"Elapsed: {time.perf_counter() - t0:.3f}s")
    print(results[0])

asyncio.run(main())
Elapsed: 0.404s
task 0 done

run_in_executor(None, ...) uses the default ThreadPoolExecutor. You can pass a ProcessPoolExecutor instead for CPU-bound work that needs to escape the GIL. Pass None as the first argument to use the default thread pool.

When to use each

Situation Best tool Why
Many concurrent network requests, API calls, or DB queries asyncio Lowest overhead; scales to thousands of concurrent tasks
Integrating a blocking library into async code asyncio + run_in_executor Keeps the event loop unblocked
I/O-bound work with a synchronous (non-async) design threading Simpler than async; works well for moderate task counts
CPU-heavy work: image processing, parsing, ML inference multiprocessing Only option that bypasses the GIL
Mixing CPU and I/O (e.g. fetch then process) asyncio + ProcessPoolExecutor I/O stays async; CPU work offloaded to processes
Firing off a few background tasks with no coordination needed threading Lower setup cost than asyncio when there's no event loop already running

Practical notes

asyncio scales better than threading for large concurrency. Each OS thread consumes ~8MB of stack by default. A thousand threads means ~8GB just in stack memory. asyncio tasks are lightweight coroutines β€” you can run tens of thousands without issue.

threading requires care with shared state. If multiple threads read and write the same data, you need locks (threading.Lock). Asyncio avoids this because only one coroutine runs at a time (no preemption between await points).

multiprocessing has a startup cost. Spawning a process takes roughly 50–200ms depending on the OS. Use a persistent Pool for repeated work rather than creating new processes per task.

Python 3.13+ is experimenting with a per-thread GIL (PEP 703 "free-threaded" CPython). If you enable the experimental free-threaded build, threads can run Python bytecode in parallel. As of 3.12, this is not the default.

Quick decision tree

Is the work I/O-bound (waiting on network, disk, database)?
β”œβ”€β”€ Yes β†’ asyncio (preferred) or threading
β”‚         └── Does the library support async/await?
β”‚             β”œβ”€β”€ Yes β†’ asyncio directly
β”‚             └── No  β†’ asyncio + run_in_executor OR threading
└── No (CPU-bound: computation, parsing, encoding) β†’ multiprocessing

Tested on Ubuntu 24.04 with Python 3.12.3.

References

python asyncio threading multiprocessing concurrency GIL
NanoTech Insight
Published by
NanoTech Insight
Independent technology site

This article was drafted with AI assistance and grounded in the references linked above. Technology changes quickly, so please verify details against official documentation before relying on them in production.

Related Articles

git push rejected non-fast-forward: Causes and Fixes
2026-10-20
Python list comprehension vs generator: when to use which
2026-10-16
json.decoder.JSONDecodeError: Expecting value: line 1 column 1
2026-10-13
git fatal: refusing to merge unrelated histories
2026-10-09
← Back to Home