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.

Parallel task lanes whose waiting periods overlap

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 def defines a coroutine. Calling it does not run it; you await it or hand it to the event loop.
  • await says "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.

Keep reading