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
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 insideasync 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.
Master Python concurrency and async programming
→ View Course on Udemy — Hands-on video course covering every concept in this post and more.
Sponsored link. We may earn a commission at no extra cost to you.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
