Async and await
async/await runs concurrent I/O on a single thread via asyncio. TaskGroup (3.11+) gives fail-fast fan-out with structured cancellation.
async def declares a coroutine - a function that can pause while waiting for I/O and let other work run in the meantime. await is how you pause: it suspends the current coroutine until the thing you are awaiting completes. asyncio.run(main()) starts the event loop and runs your top-level coroutine. Important to say upfront: asyncio is concurrency, not parallelism. A single CPython process runs one Python bytecode instruction at a time, because the Global Interpreter Lock (GIL) prevents two threads from executing Python simultaneously. What asyncio gives you is the ability to overlap I/O waits - while one coroutine waits for a network response, another can run.
An async def function returns a coroutine object when called - it does not start running yet. You must await it (inside another async function) or hand it to asyncio.run() at the top level. await asyncio.sleep(n) is the async equivalent of time.sleep(n): it pauses the coroutine for n seconds without blocking the event loop thread.
import asyncio
async def fetch(url: str) -> str:
# Simulate a network round-trip
await asyncio.sleep(0.1)
return f"response from {url}"
async def main() -> None:
result = await fetch("https://example.com/api")
print(result)
asyncio.run(main())
# response from https://example.com/apiasyncio.gather() runs multiple coroutines concurrently and waits for all of them. Because the coroutines overlap their I/O waits, the total time is roughly the duration of the slowest one - not the sum of all of them. One important caveat: by default, gather does not cancel sibling coroutines when one of them fails. If task B raises an exception, tasks A and C keep running until they finish or you explicitly cancel them.
import asyncio
import time
async def fetch(label: str, delay: float) -> str:
await asyncio.sleep(delay)
return f"{label} done"
async def main() -> None:
start = time.perf_counter()
# Run three fetches concurrently
# Total time is ~0.3s (the slowest), not 0.1+0.2+0.3 = 0.6s
results = await asyncio.gather(
fetch("a", 0.1),
fetch("b", 0.2),
fetch("c", 0.3),
)
elapsed = time.perf_counter() - start
print(results) # ['a done', 'b done', 'c done']
print(f"{elapsed:.2f}s") # ~0.30s
asyncio.run(main())asyncio.TaskGroup (added in Python 3.11) gives you fail-fast fan-out with structured cancellation. When any task inside the group raises an exception, the group cancels all remaining tasks and then raises an ExceptionGroup containing all the errors that occurred. This is safer than gather for most fan-out work because no task silently keeps running after a sibling has already failed.
import asyncio
async def fetch(label: str, should_fail: bool) -> str:
await asyncio.sleep(0.1)
if should_fail:
raise ValueError(f"{label} failed")
return f"{label} ok"
async def main() -> None:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch("a", should_fail=False))
tg.create_task(fetch("b", should_fail=True))
tg.create_task(fetch("c", should_fail=True))
# If we reach here, all tasks succeeded
except* ValueError as eg:
# except* handles ExceptionGroup members by type
for err in eg.exceptions:
print(f"Caught: {err}")
# Output:
# Caught: b failed
# Caught: c failed
asyncio.run(main())In production
asyncio is concurrency, not parallelism - a single CPython process runs one Python bytecode instruction at a time under the GIL; asyncio overlaps I/O waits, not CPU work. For CPU-bound workloads reach for multiprocessing or concurrent.futures.ProcessPoolExecutor. asyncio.gather(*tasks) does not cancel sibling tasks on first failure by default; prefer async with asyncio.TaskGroup() (3.11+) for fail-fast semantics with structured cancellation.
Enjoyed this? Get more essays on software craft delivered to your inbox.
Subscribe free