async/await in Python: asyncio explained with timings
async/await lets one thread wait on many slow tasks at once. See gather, semaphores and timeouts with real timings, and the blocking-call mistake.

Most programs spend their time waiting: for a web request, a database, a file. async/await lets one thread start a wait and switch to other work meanwhile, so many waits overlap. It does not make CPU-heavy code faster, and it does not run code in parallel on several cores. The code and timings below were run with Python 3 before publishing (sleep stands in for a network call).
The core ideas
async defdefines a coroutine. Calling it does not run it; youawaitit or hand it to the event loop.awaitsays "pause me here until this finishes, and let others run".asyncio.run(main())starts the event loop.asyncio.gather(...)runs several awaitables concurrently and collects the results in order.
Sequential vs concurrent, a semaphore and a timeout
import asyncio, time
async def fetch(name, delay):
await asyncio.sleep(delay) # stands in for a network call
return f"{name} done"
async def main():
t = time.perf_counter()
for n in ("a", "b", "c"):
await fetch(n, 0.3) # one after another
print(f"one after another: {time.perf_counter() - t:.1f} s")
t = time.perf_counter()
results = await asyncio.gather(fetch("a", 0.3), fetch("b", 0.3), fetch("c", 0.3))
print(f"gather: {time.perf_counter() - t:.1f} s ->", results)
sem = asyncio.Semaphore(2) # at most two at once
active, peak = 0, 0
async def limited(i):
nonlocal active, peak
async with sem:
active += 1; peak = max(peak, active)
await asyncio.sleep(0.1)
active -= 1
await asyncio.gather(*(limited(i) for i in range(6)))
print("semaphore(2): at most", peak, "running at once")
try:
await asyncio.wait_for(fetch("slow", 1.0), timeout=0.2)
except asyncio.TimeoutError:
print("timeout: gave up after 0.2 s")
asyncio.run(main())
one after another: 0.9 s
gather: 0.3 s -> ['a done', 'b done', 'c done']
semaphore(2): at most 2 running at once
timeout: gave up after 0.2 s
Three waits of 0.3 s took 0.9 s one after another and 0.3 s with gather. The semaphore held the number of tasks running at once at 2, which is how you avoid hammering a service with hundreds of requests. wait_for stopped waiting after 0.2 s for a task that needed 1 s.
The mistake that freezes everything
Everything shares one thread, so one blocking call stops all the tasks.
async def slow_block():
time.sleep(0.5) # a blocking call inside async code freezes everything
ticks = []
async def ticker():
for _ in range(3):
ticks.append(round(time.perf_counter() - t0, 1)); await asyncio.sleep(0.1)
t0 = time.perf_counter()
await asyncio.gather(ticker(), slow_block())
print("ticker ticks while blocked by time.sleep:", ticks)
ticker ticks while blocked by time.sleep: [0.0, 0.5, 0.6]
The ticker should tick every 0.1 s. Instead it ticked at 0.0 and then waited until 0.5, because time.sleep(0.5) blocked the loop. Use await asyncio.sleep instead, or move blocking work to a thread with asyncio.to_thread(...).
When to use asyncio
- Good fit: many concurrent network calls, web servers, chat, scraping many pages politely, anything that mostly waits.
- Poor fit: heavy computation (use processes), simple scripts with one or two calls, code that depends on blocking libraries.
Common mistakes
- Calling a coroutine without
await, which only creates an object and runs nothing (Python warns "coroutine was never awaited"). - Using blocking libraries (
requests,time.sleep) in async code. - Starting thousands of tasks at once with no limit. Use a semaphore.
- Letting one failed task hide the others.
gather(..., return_exceptions=True)returns errors as results. - Forgetting timeouts. A request with no timeout can wait forever.
Main idea: use async/await for code that waits, run independent waits with gather, limit concurrency with a semaphore, set timeouts, and never block the loop.
Programming roadmaps series, Part 3 (writing better Python): 3 of 5. Previous: Regular expressions in Python: a practical guide with the traps.


