Python asyncio vs Threading: The Benchmark That Changes How You Think About Concurrency

Python asyncio vs Threading: The Benchmark That Changes How You Think About Concurrency
29 views

The question “asyncio or threading?” gets asked constantly and answered badly. “Use asyncio for I/O, threading for CPU” breaks down the moment your workload is mixed or your library lacks async support. Here are the actual benchmarks.

⚡ TL;DR: asyncio wins for 1000+ concurrent I/O connections with 8000x less memory than threads. Threading wins for mixed sync/async codebases. Multiprocessing wins for CPU-bound work. The GIL makes threading useless for CPU parallelism but fine for I/O.

The Core Difference

import asyncio, threading, time, requests, aiohttp

URLS = [f"https://httpbin.org/delay/0.1" for _ in range(50)]

# Sequential baseline
def sequential():
 for url in URLS: requests.get(url)
# Result: 5.2s

# Threading
def threaded():
 threads = [threading.Thread(target=requests.get, args=(url,)) for url in URLS]
 for t in threads: t.start()
 for t in threads: t.join()
# Result: 0.35s

# asyncio
async def async_fetch():
 async with aiohttp.ClientSession() as session:
 await asyncio.gather(*[session.get(url) for url in URLS])
# Result: 0.18s

# asyncio ~2x faster than threading for pure I/O
# Thread creation + context switching have real costs

Memory Benchmark: asyncio Scales 8000x Further

import tracemalloc, asyncio, threading, time
N = 10_000

# Threading: ~8MB per thread stack
# 10,000 threads = ~80GB — impossible, OS rejects above ~1000 threads

# asyncio: ~1KB per coroutine
tracemalloc.start()
async def many_coroutines():
 await asyncio.gather(*[asyncio.sleep(1) for _ in range(N)])
asyncio.run(many_coroutines())
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"10,000 coroutines: {peak/1024/1024:.1f} MB")
# Result: ~10 MB — fits in memory, threads never could

When Threading Beats asyncio

import boto3 # No async support
import concurrent.futures

def upload_file(key, data):
 s3 = boto3.client('s3')
 s3.put_object(Bucket='my-bucket', Key=key, Body=data)

# Threading works fine — boto3 releases GIL during network I/O
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as ex:
 futures = [ex.submit(upload_file, f'file-{i}', b'data') for i in range(100)]
 concurrent.futures.wait(futures)

# asyncio requires async/await throughout entire call chain
# Most legacy libraries lack async support — threading is simpler

Never Block the Event Loop

import asyncio

async def bad():
 time.sleep(5) # BLOCKS entire event loop for 5 seconds!

async def good():
 await asyncio.sleep(5) # Yields control — other tasks run

# Run blocking code from async context:
async def mixed():
 loop = asyncio.get_event_loop()
 result = await loop.run_in_executor(None, blocking_function, arg)
 return result

Measure it yourself: threads vs processes for CPU-bound work

The benchmarks above are about I/O. For CPU-bound Python code the question is different, because in the default CPython build the global interpreter lock (GIL) lets only one thread execute Python bytecode at a time. Threads still help while code waits on the network or disk, but pure-Python computation doesn’t get faster with more threads. Processes each have their own interpreter and GIL, so they can use several cores, at the cost of starting processes and pickling arguments and results between them.

concurrent.futures gives both models the same API, which makes the comparison easy to run on your own hardware:

python
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor


def count(n: int) -> int:
    total = 0
    for i in range(n):
        total += i
    return total


def timed(label, fn):
    start = time.perf_counter()
    fn()
    print(f"{label:<10} {time.perf_counter() - start:.2f}s")


if __name__ == "__main__":
    N, WORKERS = 10_000_000, 4
    timed("serial", lambda: [count(N) for _ in range(WORKERS)])
    with ThreadPoolExecutor(WORKERS) as ex:
        timed("threads", lambda: list(ex.map(count, [N] * WORKERS)))
    with ProcessPoolExecutor(WORKERS) as ex:
        timed("processes", lambda: list(ex.map(count, [N] * WORKERS)))

On our 8-core test machine with CPython 3.13 (default build), threads took about as long as the serial loop, and the process pool finished roughly 2.7 times faster with 4 workers. Your numbers will differ, so run it on the hardware you deploy to. The shape of the result is what matters: threads give no speedup for pure-Python CPU work under the GIL, and processes do.

Two caveats. First, many libraries that do heavy computation in C (NumPy, for example) release the GIL inside their own code, so threads can help there. Second, since Python 3.13 CPython also offers an optional free-threaded build with the GIL disabled, where threads can run Python code in parallel. It has some single-threaded overhead, and extension modules that aren’t marked as supporting it can re-enable the GIL, so check sys._is_gil_enabled() before you rely on it. See the Python free-threading guide.

Decision Guide

  • ✅ asyncio: 1000+ concurrent connections, library supports async
  • ✅ ThreadPoolExecutor: legacy sync libraries (boto3, requests, psycopg2)
  • ✅ multiprocessing: CPU-bound work — image processing, ML inference
  • ✅ run_in_executor(): run sync code inside async context
  • ❌ Never call time.sleep() or blocking I/O inside async def
  • ❌ Never use threading for CPU-bound work — GIL prevents parallelism

The GIL mechanics behind threading limitations are in the Python GIL guide. For memory optimization in async systems, Python __slots__ reduces per-object overhead that matters when asyncio manages thousands of connection objects. External reference: Python asyncio documentation.

Related: Python asyncio vs Threading vs Multiprocessing: When to Use Each · asyncio TaskGroups: Structured Concurrency That Cancels Cleanly Under Load · Python Lambda Warm Strategies: Lean Imports Plus Provisioned Concurrency

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.