Async/Await - Concurrent I/O Without Blocking (Python Tutorial #31)
Video: Async/Await - Concurrent I/O Without Blocking (Python Tutorial #31) by Taught by Celeste AI - AI Coding Coach
Python Async/Await: asyncio for I/O-bound concurrency
async defdefines a coroutine.awaitpauses until an awaitable resolves.asyncio.run(main())runs the event loop.asyncio.gather(...)runs multiple coroutines concurrently. Async is for I/O-bound work — multiple network calls, file reads — not CPU-bound work (use threads/processes for that).
When your program waits on the network or disk, it's idle. Async lets you start the next operation while the previous waits — concurrency without threads.
Sync vs async
import time
def fetch_sync(name, delay):
print(f"Fetching {name}...")
time.sleep(delay)
return f"{name} done"
start = time.time()
r1 = fetch_sync("acme.com", 1.0)
r2 = fetch_sync("globex.com", 1.0)
print(f"Took {time.time() - start:.2f}s")
# Took 2.00s — sequential
Two 1-second waits → 2 seconds total. Each blocks before the next starts.
import asyncio
async def fetch_async(name, delay):
print(f"Fetching {name}...")
await asyncio.sleep(delay)
return f"{name} done"
async def main():
start = time.time()
r1, r2 = await asyncio.gather(
fetch_async("acme.com", 1.0),
fetch_async("globex.com", 1.0)
)
print(f"Took {time.time() - start:.2f}s")
asyncio.run(main())
# Took 1.00s — concurrent
Both fetches start, both wait, both resume. The total time is the maximum of any one operation, not the sum.
async def: defining a coroutine
async def my_function():
return 42
async def makes a coroutine function. Calling it returns a coroutine object — it doesn't run yet.
coro = my_function() # NOT executed
print(coro) # <coroutine object my_function at ...>
result = await coro # NOW it runs (only inside another async function)
await: wait for the result
await pauses the current coroutine until the awaited thing finishes. While paused, the event loop runs other coroutines.
You can await:
- Coroutines (async def function calls).
asyncio.TaskandFutureobjects.- Anything implementing
__await__.
You can NOT use await outside an async def. (Except in REPL or top-level scripts via asyncio.run.)
asyncio.run(): the entry point
async def main():
await something()
asyncio.run(main())
asyncio.run() creates an event loop, runs the coroutine to completion, then closes the loop. Use it once at the top of your program.
In Jupyter, the loop is already running — just await main() directly.
asyncio.gather: concurrent execution
results = await asyncio.gather(
fetch_async("acme.com", 1.0),
fetch_async("globex.com", 1.0),
fetch_async("initech.com", 1.0)
)
# results is a list in the same order as the inputs
gather schedules all coroutines and waits for all to complete. Returns results in the same order as inputs.
If any coroutine raises, gather raises immediately and cancels the rest. To collect errors instead:
results = await asyncio.gather(
...,
return_exceptions=True
)
for r in results:
if isinstance(r, Exception):
print(f"Failed: {r}")
else:
print(f"Got: {r}")
asyncio.create_task: background tasks
async def main():
task1 = asyncio.create_task(process_order(101, 1.0))
task2 = asyncio.create_task(process_order(102, 0.8))
# Both running now in the background
# Do other work...
results = await asyncio.gather(task1, task2)
create_task schedules a coroutine to run immediately in the background. Returns a Task (which is a Future). You can await it later, or cancel() it.
gather is a higher-level convenience: it wraps coroutines in tasks for you.
Timeouts: asyncio.wait_for
try:
result = await asyncio.wait_for(
slow_fetch("initech.com", 3.0),
timeout=1.0
)
except asyncio.TimeoutError:
print("Took too long")
If the coroutine doesn't finish within timeout seconds, it's cancelled and TimeoutError is raised.
In Python 3.11+, asyncio.timeout() is the modern context-manager form:
async with asyncio.timeout(1.0):
result = await slow_fetch("initech.com", 3.0)
When async helps (and when it doesn't)
Async helps for I/O-bound work:
- Multiple HTTP requests.
- Database queries.
- File reads/writes.
- Anything that spends most time waiting.
Async does NOT help for CPU-bound work:
- Number crunching.
- Image processing.
- Compression.
Why? Async is single-threaded — only one coroutine runs at a time. While CPU is busy in one coroutine, others are blocked. For CPU work, use multiprocessing or concurrent.futures.ProcessPoolExecutor.
A sync call in async = the kiss of death
async def bad():
time.sleep(5) # blocks the event loop! everything else stops.
await fetch_data()
Inside an async function, never call blocking sync code (time.sleep, requests.get, open().read() for big files). Use the async equivalent (asyncio.sleep, aiohttp.get, aiofiles.open).
If you must run sync code, use asyncio.to_thread:
async def good():
await asyncio.to_thread(blocking_function, arg1, arg2)
This runs the sync function in a thread pool, freeing the event loop.
Async libraries
For async to be useful, your I/O library must be async-aware:
- aiohttp — async HTTP (replaces
requests). - httpx — supports both sync and async.
- aiofiles — async file I/O.
- asyncpg — PostgreSQL.
- motor — MongoDB.
- databases — generic async DB.
Mix sync and async carefully. Better: pick a stack that's async all the way down.
Async iteration
async def fetch_pages():
for url in urls:
yield await fetch(url)
async for page in fetch_pages():
process(page)
async for iterates an async iterator. async def with yield defines an async generator.
Async context manager
async with aiohttp.ClientSession() as session:
async with session.get("https://example.com") as r:
text = await r.text()
async with calls __aenter__ / __aexit__ (the async equivalents of __enter__/__exit__). For resources that need async setup or teardown.
Common patterns
# Run with concurrency limit
async def fetch_all_throttled(urls, max_concurrent=5):
sem = asyncio.Semaphore(max_concurrent)
async def with_sem(url):
async with sem:
return await fetch(url)
return await asyncio.gather(*[with_sem(u) for u in urls])
A semaphore limits concurrent operations — useful for rate-limited APIs.
Common stumbles
Calling an async function without await. Returns a coroutine object, doesn't run. Add await.
await outside async def. SyntaxError. Wrap in an async function or use asyncio.run.
Forgetting asyncio.run. await main() in a script crashes — there's no event loop.
time.sleep inside async. Blocks everything. Use asyncio.sleep.
Synchronous library inside async. Same problem — use the async version, or asyncio.to_thread.
Forgetting to await gather/create_task. Coroutine warnings, no concurrency. Always await.
CPU-heavy code in async. Other coroutines starve. Use a process pool for CPU work.
Catching all exceptions including CancelledError. Tasks need to be cancellable. Catch Exception, not BaseException.
What's next
Lesson 32: HTTP requests with the requests library. GET, POST, headers, JSON, query params, sessions.
Recap
async def defines a coroutine; await pauses until done. asyncio.run(main()) is the entry point. asyncio.gather(...) runs many coroutines concurrently; asyncio.create_task(...) schedules in the background. Async is for I/O-bound work — calling APIs, reading files. Don't call blocking sync code inside async; use async-aware libraries (aiohttp, aiofiles) or asyncio.to_thread. Use asyncio.wait_for or asyncio.timeout for limits.
Next lesson: HTTP requests with requests.