Modern applications need to handle thousands of concurrent tasks efficiently — from web servers handling simultaneous requests to data pipelines fetching from multiple APIs. Python offers two main concurrency models: threading and asyncio. Knowing when to use each is a superpower for any developer.
Concurrency vs Parallelism
Before diving in, let’s clarify key terms:
| Concept | Definition | Python Support |
|---|---|---|
| Concurrency | Multiple tasks make progress interleaved | Threading, asyncio |
| Parallelism | Multiple tasks run simultaneously on multiple CPUs | multiprocessing |
| Asynchronous | Tasks yield control while waiting (I/O bound) | asyncio |
| Synchronous | Tasks block until complete | Default Python |
💡 Due to the GIL (Global Interpreter Lock), Python threads cannot achieve true CPU parallelism. But they work well for I/O-bound tasks.
Threading Model
Python’s threading module allows you to run multiple threads within a single process. Each thread runs independently but shares the same memory space.
Basic Threading
import threading
import time
def download_file(name, duration):
print(f"[{name}] Starting download...")
time.sleep(duration) # Simulates I/O wait
print(f"[{name}] Download complete! ({duration}s)")
# Without threading: 6 seconds total
t_start = time.time()
download_file("File A", 2)
download_file("File B", 2)
download_file("File C", 2)
print(f"Sequential: {time.time() - t_start:.1f}s") # ~6.0s
# With threading: ~2 seconds total (concurrent I/O)
t_start = time.time()
threads = [
threading.Thread(target=download_file, args=("File A", 2)),
threading.Thread(target=download_file, args=("File B", 2)),
threading.Thread(target=download_file, args=("File C", 2)),
]
for t in threads: t.start()
for t in threads: t.join() # Wait for all to complete
print(f"Threaded: {time.time() - t_start:.1f}s") # ~2.0s
Thread Safety with Locks
Shared data across threads can cause race conditions. Use Lock() to protect critical sections:
import threading
counter = 0
lock = threading.Lock()
def increment(n):
global counter
for _ in range(n):
with lock: # Only one thread at a time
counter += 1
threads = [threading.Thread(target=increment, args=(10000,)) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Final counter: {counter}") # Always 50000 — safe!
asyncio: Cooperative Concurrency
asyncio uses a single-threaded event loop where tasks voluntarily yield control using await. This is ideal for I/O-bound work (network, file I/O) with minimal overhead.
The async/await Syntax
import asyncio
import aiohttp # pip install aiohttp
async def fetch_url(session, url):
print(f"→ Fetching: {url}")
async with session.get(url) as response:
data = await response.text()
print(f"✅ Got {len(data)} chars from {url}")
return data
async def main():
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
async with aiohttp.ClientSession() as session:
# All three requests run concurrently!
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
print(f"Fetched {len(results)} URLs concurrently")
asyncio.run(main()) # ~1s instead of ~3s
Async Generators
import asyncio
async def async_range(start, stop, delay=0.1):
"""Async generator producing values with pauses"""
for i in range(start, stop):
await asyncio.sleep(delay) # Non-blocking pause
yield i
async def main():
async for value in async_range(0, 5):
print(f"Received: {value}")
asyncio.run(main())
Task Management
import asyncio
async def worker(name, seconds):
print(f"{name}: Starting ({seconds}s task)")
await asyncio.sleep(seconds)
print(f"{name}: Done!")
return f"{name}_result"
async def main():
# Create tasks to run concurrently
task_a = asyncio.create_task(worker("Alpha", 2))
task_b = asyncio.create_task(worker("Beta", 1))
task_c = asyncio.create_task(worker("Gamma", 3))
# Wait for first completion
done, pending = await asyncio.wait(
[task_a, task_b, task_c],
return_when=asyncio.FIRST_COMPLETED
)
print(f"First done: {done.pop().result()}")
# Cancel remaining tasks
for task in pending:
task.cancel()
asyncio.run(main())
asyncio vs Threading: When to Use What
I/O-Bound CPU-Bound
(network, files) (computation)
┌────────────┐ ┌───────────────────┐
│ asyncio │ │ multiprocessing │
│ threading│ │ (NOT threading) │
└────────────┘ └───────────────────┘
asyncio → Thousands of concurrent I/O ops, minimal overhead
threading → Simpler code, existing blocking libraries, <100 threads
multiprocessing → CPU-intensive work (ML, image processing, hashing)
Practical Benchmarks
import asyncio, threading, time, random
# Simulated I/O (e.g., database query delay)
async def async_task(n):
await asyncio.sleep(random.uniform(0.05, 0.15))
return n ** 2
def thread_task(n):
import time
time.sleep(random.uniform(0.05, 0.15))
return n ** 2
N = 100
# asyncio benchmark
async def async_bench():
start = time.time()
results = await asyncio.gather(*[async_task(i) for i in range(N)])
print(f"asyncio: {time.time()-start:.2f}s for {N} tasks")
asyncio.run(async_bench())
# threading benchmark
start = time.time()
threads = [threading.Thread(target=thread_task, args=(i,)) for i in range(N)]
for t in threads: t.start()
for t in threads: t.join()
print(f"threading: {time.time()-start:.2f}s for {N} tasks")
🤖 Interactive AI Widget: Event Loop Visualizer
See how the asyncio event loop schedules and switches between coroutines. Click “Run Simulation” to watch tasks execute cooperatively.
Key Takeaways
- Use asyncio when you have many concurrent I/O-bound operations (HTTP requests, DB queries, WebSockets).
- Use threading when integrating with blocking libraries that don’t support async (legacy SDKs, some DB drivers).
- Use multiprocessing for CPU-intensive work (image processing, ML inference, cryptographic hashing).
- Never mix blocking calls inside an
async deffunction — useloop.run_in_executor()for that.
Practice Exercises
- Use
asyncio.gatherto fetch 10 URLs simultaneously and measure the speedup vs sequential. - Build a thread-safe counter class with
threading.Lockand test it with 10 concurrent threads. - Implement an async producer-consumer pattern using
asyncio.Queue. - Profile the GIL impact: run a CPU-intensive task with threading vs multiprocessing.
📚 Recommended Reading: Using Asyncio in Python by Caleb Hattingh. Python docs:
asyncio— High-level API.