Concurrency & Async Python
Modern applications need to handle many tasks at once: downloading files, calling APIs, querying databases, processing requests. Python offers multiple tools for concurrency — asyncio, threads, and processes. Knowing when and how to use each one is a key skill for writing fast, efficient Python programs.
Why This Chapter Matters
Without concurrency, your programs wait idle for I/O (network, files, databases) instead of doing useful work. Understanding async programming and concurrency makes your applications significantly faster and more scalable.
The Three Concurrency Models
| Model | Best For | Python Tool |
|---|---|---|
| Async (cooperative) | Many I/O-bound tasks (API calls, DB queries) | asyncio, async/await |
| Threading | I/O-bound tasks, simpler code | threading, concurrent.futures |
| Multiprocessing | CPU-bound tasks (computation, image processing) | multiprocessing, concurrent.futures |
Synchronous vs Asynchronous
Synchronous (blocking)
import time
def download(url):
time.sleep(2) # simulates network delay
return f"Content from {url}"
# Downloads one at a time — 6 seconds total
for url in ["a.com", "b.com", "c.com"]:
print(download(url))
Asynchronous (non-blocking)
import asyncio
async def download(url):
await asyncio.sleep(2) # simulates async network delay
return f"Content from {url}"
async def main():
# All three run concurrently — ~2 seconds total
results = await asyncio.gather(
download("a.com"),
download("b.com"),
download("c.com"),
)
print(results)
asyncio.run(main())
asyncio Fundamentals
Coroutines
A coroutine is a function defined with async def. It can be "paused" at await points to let other coroutines run.
import asyncio
async def say_hello():
print("Hello...")
await asyncio.sleep(1)
print("...World!")
asyncio.run(say_hello())
await
await pauses the current coroutine and gives control back to the event loop while waiting for the result.
async def fetch_data():
print("Fetching...")
await asyncio.sleep(2) # non-blocking pause
return {"data": 42}
async def main():
result = await fetch_data()
print(result)
asyncio.run(main())
Running Multiple Coroutines: asyncio.gather()
import asyncio
async def task(name, delay):
await asyncio.sleep(delay)
print(f"{name} done after {delay}s")
return name
async def main():
results = await asyncio.gather(
task("Download", 2),
task("Upload", 1),
task("Process", 3),
)
print("All done:", results)
asyncio.run(main())
# Total time ~3s (longest task), not 6s (2+1+3)
Creating Tasks
asyncio.create_task() starts a coroutine immediately and lets other coroutines run while it works:
async def main():
task1 = asyncio.create_task(task("A", 2))
task2 = asyncio.create_task(task("B", 1))
result1 = await task1
result2 = await task2
print(result1, result2)
Timeouts
import asyncio
async def slow_operation():
await asyncio.sleep(5)
return "done"
async def main():
try:
result = await asyncio.wait_for(slow_operation(), timeout=2.0)
except asyncio.TimeoutError:
print("Operation timed out!")
asyncio.run(main())
Async HTTP Requests with httpx
For real async HTTP requests, use httpx:
pip install httpx
import asyncio
import httpx
async def fetch(url):
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.json()
async def main():
urls = [
"https://jsonplaceholder.typicode.com/posts/1",
"https://jsonplaceholder.typicode.com/posts/2",
"https://jsonplaceholder.typicode.com/posts/3",
]
results = await asyncio.gather(*[fetch(url) for url in urls])
for r in results:
print(r["title"])
asyncio.run(main())
Threading
Threads share memory and are good for I/O-bound tasks. The Python GIL (Global Interpreter Lock) limits true parallelism for CPU work.
import threading
import time
def worker(name, delay):
print(f"{name} starting")
time.sleep(delay)
print(f"{name} done")
threads = [
threading.Thread(target=worker, args=("Thread-1", 2)),
threading.Thread(target=worker, args=("Thread-2", 1)),
]
for t in threads:
t.start()
for t in threads:
t.join() # wait for all threads to finish
print("All threads done")
concurrent.futures.ThreadPoolExecutor
A higher-level interface for threading:
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def slow_job(n):
time.sleep(1)
return n * n
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(slow_job, i) for i in range(8)]
for future in as_completed(futures):
print(future.result())
Multiprocessing
Multiprocessing creates separate OS processes — each has its own memory and Python interpreter. This bypasses the GIL and is true parallelism for CPU-bound work.
from concurrent.futures import ProcessPoolExecutor
import time
def cpu_intensive(n):
# Simulate heavy computation
return sum(i ** 2 for i in range(n))
with ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_intensive, [100_000, 200_000, 300_000]))
print(results)
Choosing the Right Concurrency Tool
Is your task...
Waiting for network/file/database?
→ Use asyncio (best) or threading
Doing heavy computation (math, image, ML)?
→ Use multiprocessing
Calling a library that isn't async-compatible?
→ Use threading
Running FastAPI/web server?
→ async def routes + asyncio automatically
Async Context Managers
async def main():
async with httpx.AsyncClient() as client:
resp = await client.get("https://example.com")
print(resp.status_code)
Async Generators and Iterators
async def count_up(n):
for i in range(n):
await asyncio.sleep(0.1)
yield i
async def main():
async for value in count_up(5):
print(value)
Common Mistakes
- using
awaitoutside anasync deffunction (SyntaxError) - calling
asyncio.run()inside an already-running event loop (useawaitinstead) - using blocking code (like
time.sleep()) inside an async function — useawait asyncio.sleep()instead - using multiprocessing for I/O-bound tasks (threading or asyncio is better)
- using threading for CPU-bound tasks (multiprocessing is needed to bypass the GIL)
Mini Exercises
- Write three async coroutines that each sleep for 1 second and run them concurrently with
asyncio.gather(). - Use
asyncio.wait_for()to add a 2-second timeout to a slow coroutine. - Fetch 5 URLs concurrently using
httpx.AsyncClientand print the status codes. - Use
ThreadPoolExecutorto read 5 files concurrently. - Use
ProcessPoolExecutorto compute the sum of squares for 4 large ranges in parallel.
Review Questions
- What is the difference between concurrency and parallelism?
- When should you use
asynciovsthreadingvsmultiprocessing? - What does
awaitdo in an async function? - What is the Python GIL and why does it matter for multiprocessing?
- Why should you not use
time.sleep()inside an async function?
Reference Checklist
- I understand the difference between async, threading, and multiprocessing
- I can write async functions with
async defandawait - I can run multiple coroutines concurrently with
asyncio.gather() - I can use
ThreadPoolExecutorandProcessPoolExecutor - I know how to fetch URLs asynchronously with
httpx - I can choose the right concurrency model for a given task