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 costsMemory 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 couldWhen 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 simplerNever 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 resultMeasure 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:
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 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.
Free Weekly Newsletter
🚀 Don’t Miss the Next Cheat Code
You just read something most developers never learn. Get more secrets like this delivered every week — JavaScript internals, Python optimizations, AWS architectures, system design, and AI workflows.
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.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Newly added
- AWS Systems Manager Parameter Store: Hierarchical Runtime Config for Coding Agents
- Amazon Cognito Identity Pools: Federate Coding-Agent Tool Callers Without Long-Lived Keys
- Amazon Athena for Coding Agents: Query Tool Audit Logs and Eval Traces on S3 Without a Warehouse
- AWS Batch for Coding Agents: Parallel Overnight PR Reviews and Long-Running Jobs Without Lambda Timeouts
- Amazon Bedrock Prompt Management: Version and A/B Test Coding-Agent System Prompts
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.
