Skip to content

description: "Concurrency II — Asyncio — Calling say_after(1, 'hello') doesn't run the function yet — it creates a coroutine object. await is what actually drives it…"---

05 · Concurrency II — Asyncio

🎥 Video walkthrough

asyncio gives you concurrency for I/O-bound work using a single thread and an event loop, instead of OS threads. Instead of the operating system switching between threads, your own code cooperatively yields control at await points — cheaper, and free of the locking concerns from the previous module.

async def and await

import asyncio

async def say_after(delay, message):
    await asyncio.sleep(delay)   # non-blocking sleep — yields control to the event loop
    print(message)
    return message


async def main():
    result = await say_after(1, "hello")
    print(f"got: {result}")


asyncio.run(main())

Calling say_after(1, "hello") doesn't run the function yet — it creates a coroutine object. await is what actually drives it, running it until completion (or until it hits its own await).

Why sequential await doesn't help concurrency

import asyncio
import time

async def task(name, delay):
    await asyncio.sleep(delay)
    print(f"{name} done")


async def main():
    start = time.perf_counter()
    await task("A", 1)
    await task("B", 1)
    print(f"sequential: {time.perf_counter() - start:.2f}s")   # ~2s — one after another


asyncio.run(main())

Awaiting each task one at a time is no different from calling them synchronously — the concurrency benefit only shows up once you schedule multiple coroutines to run together.

asyncio.gather — run coroutines concurrently

import asyncio
import time

async def task(name, delay):
    await asyncio.sleep(delay)
    print(f"{name} done")
    return name


async def main():
    start = time.perf_counter()
    results = await asyncio.gather(task("A", 1), task("B", 1), task("C", 1))
    print(f"concurrent: {time.perf_counter() - start:.2f}s")   # ~1s — they ran together
    print(results)   # ['A', 'B', 'C']


asyncio.run(main())

While one task is inside asyncio.sleep (waiting), the event loop runs the others — all on a single thread.

Creating and managing tasks

asyncio.gather is convenient, but asyncio.create_task gives you more control: the coroutine starts running in the background immediately, and you choose when (or whether) to await its result.

import asyncio

async def worker(n):
    await asyncio.sleep(0.5)
    return n * n


async def main():
    task1 = asyncio.create_task(worker(2))   # starts running now
    task2 = asyncio.create_task(worker(3))   # also starts running now

    print("tasks are running in the background...")
    result1 = await task1
    result2 = await task2
    print(result1, result2)   # 4 9


asyncio.run(main())

Handling errors in concurrent tasks

By default, asyncio.gather cancels remaining tasks and re-raises the first exception it hits — pass return_exceptions=True to instead collect exceptions alongside successful results.

import asyncio

async def might_fail(n):
    await asyncio.sleep(0.1)
    if n == 2:
        raise ValueError(f"failed on {n}")
    return n * 10


async def main():
    results = await asyncio.gather(
        might_fail(1), might_fail(2), might_fail(3),
        return_exceptions=True,
    )
    for r in results:
        if isinstance(r, Exception):
            print("error:", r)
        else:
            print("ok:", r)


asyncio.run(main())
# ok: 10
# error: failed on 2
# ok: 30

A realistic pattern: concurrent "fetches" with a limit

Real-world async code often fetches many things concurrently but caps how many run at once, using a semaphore, to avoid overwhelming a server.

import asyncio
import random

async def fetch(session_id, semaphore):
    async with semaphore:                     # only N fetches run at once
        await asyncio.sleep(random.uniform(0.1, 0.3))
        return f"result-{session_id}"


async def main():
    semaphore = asyncio.Semaphore(3)          # at most 3 concurrent
    tasks = [fetch(i, semaphore) for i in range(10)]
    results = await asyncio.gather(*tasks)
    print(results)


asyncio.run(main())

Timeouts

import asyncio

async def slow_operation():
    await asyncio.sleep(5)
    return "finished"


async def main():
    try:
        result = await asyncio.wait_for(slow_operation(), timeout=1)
        print(result)
    except asyncio.TimeoutError:
        print("operation timed out")


asyncio.run(main())

asyncio vs. threading vs. multiprocessing

Situation Best fit
Thousands of concurrent network connections asyncio
A handful of blocking I/O calls, existing sync libraries threading
Heavy CPU computation multiprocessing
Mixing sync (blocking) code into async code run it in a thread via loop.run_in_executor

How It Actually Works

async def marks a function so that calling it doesn't run any code — it returns a coroutine object, a suspendable frame conceptually identical to a generator's (in fact, async def coroutines and generators share the same underlying frame machinery in CPython). asyncio.run(main()) creates an event loop — really just a while True: loop running in the current thread — and drives that coroutine object by repeatedly calling into it and letting it run until it hits an await on something not yet ready, at which point control returns to the loop.

await asyncio.sleep(delay) is the crucial mechanism: asyncio.sleep doesn't actually block anything — it registers a callback with the event loop's internal scheduler (via the OS's timer/select/epoll/kqueue mechanism, whichever the platform's default loop implementation uses) to be woken up after delay seconds, and then suspends the coroutine's own frame right there, handing control back to the event loop. The loop, now free, checks its list of ready callbacks and other pending tasks and runs whichever one is ready next — this is why await asyncio.sleep(1) in two coroutines finishes in ~1 second total when run concurrently (both timers are ticking down in the same loop iteration) rather than 2 seconds sequentially: it's genuinely cooperative multitasking on a single OS thread, with no GIL contention because there's only ever one thread running Python bytecode at a time by construction, not by lock.

asyncio.create_task(worker(2)) is what actually schedules a coroutine to run independently: it wraps the coroutine object in a Task, hands it to the event loop's ready queue immediately, and returns right away — this is different from await worker(2), which drives the coroutine inline in the current frame and blocks progress on this line until it's done. asyncio.gather(*coros) internally wraps each argument in a Task (scheduling all of them essentially at once) and then awaits all of them together, collecting results in the original order regardless of completion order — this is exactly why two independently-created tasks can be "running in the background" between the two lines that create them and the lines that await them.

A coroutine only ever yields control voluntarily, at an await — nothing preempts it mid-execution the way an OS thread can be preempted between bytecode instructions. This is why asyncio.Semaphore(3) limiting "concurrent" fetches works with none of threading.Lock's contention concerns: at most 3 coroutines are ever mid-flight between their own await points at once, and since only one Python frame is ever actually executing at any instant regardless, there's no possibility of two coroutines corrupting shared state through simultaneous non-atomic mutation the way threads can — the only hazard is a coroutine holding a resource across an await longer than intended, not a torn read-modify-write.

Exercise

Write an async function fetch_all(urls) that "fetches" each URL by awaiting asyncio.sleep(random.uniform(0.2, 0.6)) and returning a fake response string, running at most 4 concurrently using a Semaphore. Add error handling so that one URL "failing" (raise inside the coroutine for a specific URL) doesn't stop the others from completing, and print a summary of successes vs. failures. Time the whole run and confirm it's close to the slowest individual fetch time, not the sum of all of them.