Under the hood: internals and concurrency · about 50 min

asyncio basics

Why this matters

A chat server with 10,000 connected clients, a crawler fetching 500 pages, a bot listening on several sockets: each spends nearly all its time waiting for the network. Threads can overlap that waiting, but 10,000 OS threads cost a lot of memory and switching, and shared state needs locks. asyncio handles many waits in a single thread by switching between tasks only at points you mark with await. It powers modern Python web frameworks (FastAPI, aiohttp) and network clients, so you will meet async and await in real code soon.

The idea: cooperative multitasking

Threads are preemptive: the OS can interrupt one at any instruction. asyncio is cooperative: a task runs until it voluntarily says "I am waiting for something, let someone else run". The coordinator is the event loop:

event loop (one thread)
  ready queue: [task A, task B, task C]

  run A until it awaits a network reply  -> A parked, waiting on socket 5
  run B until it awaits sleep(0.1)        -> B parked, timer at t+0.1
  run C until it finishes                 -> C done
  nothing ready: ask the OS (epoll on Linux) "wake me when socket 5 has data
                 or in 0.1 s, whichever comes first"
  socket 5 ready -> A back in the ready queue ...

Because switches happen only at await, code between two awaits runs without interruption. That removes most race conditions without locks. The flip side: one task that computes for a long time, or makes a blocking call, freezes everyone.

Coroutines: async def and await

A function defined with async def is a coroutine function. Calling it does not run it; it returns a coroutine object, a suspended computation:

import asyncio

async def greet(name):
    print("start", name)
    await asyncio.sleep(0.1)
    print("end", name)
    return name.upper()

coro = greet("ada")
print(type(coro))           # <class 'coroutine'>
result = asyncio.run(coro)  # now it runs
print(result)
<class 'coroutine'>
start ada
end ada
ADA

Under the hood, coroutines are built on the same machinery as generators (previous module): a frame that can be suspended and resumed. An await that must wait passes control all the way back up to the event loop, which later resumes the chain.

Awaiting is not concurrency

This is the most common surprise:

async def main():
    await greet("a")
    await greet("b")
start a
end a
start b
end b

Each await waits for that coroutine to finish before the next line runs: sequential, just with extra syntax. Concurrency needs several tasks alive at once.

Running things concurrently

asyncio.gather runs several awaitables concurrently and returns their results as a list in argument order:

import asyncio, time

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

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(work("a", 0.3), work("b", 0.1), work("c", 0.2))
    print(results)
    print(f"took {time.perf_counter() - start:.1f} s")

asyncio.run(main())
['a done', 'b done', 'c done']
took 0.3 s

Total time is the longest wait, not the sum. b finished first, but results still come in argument order. If a coroutine raises, gather raises that exception (pass return_exceptions=True to get exceptions in the list instead).

Tasks start a coroutine running in the background right away:

async def main():
    task = asyncio.create_task(work("bg", 0.2))
    print("task started, doing other things")
    await asyncio.sleep(0.1)
    print(await task)     # wait for its result

Keep a reference to every task you create (the loop holds only a weak reference; an unreferenced task can be garbage collected mid-run). Python 3.11 added asyncio.TaskGroup, a structured way to run tasks that cancels the rest if one fails:

async def main():
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(work("x", 0.1))
        t2 = tg.create_task(work("y", 0.2))
    print(t1.result(), t2.result())   # all done when the block exits

Useful tools

async with and async for are the asynchronous versions of with and for, for context managers and iterators whose setup or next step involves waiting.

When to use what

SituationTool
CPU-bound pure Pythonprocesses (ProcessPoolExecutor)
A few blocking I/O calls, existing synchronous librariesthreads (ThreadPoolExecutor)
Many concurrent network connections, async libraries availableasyncio

asyncio is "viral": to benefit, the libraries you call must be async too (an async HTTP client, an async database driver). A synchronous library call inside a coroutine blocks the loop.

Common mistakes

Recap

Going further: read the "Coroutines and Tasks" page of the asyncio documentation, then write a tiny TCP echo server with asyncio.start_server and connect to it with nc localhost 8888 from two terminals at once.

Exercises

Exercise 1 · quiz

Calling an async function

async def hello():
    print("hi")

hello()

What happens when this script runs?

Answer
  • Nothing is printed: calling hello() only creates a coroutine object, and Python warns that it was never awaited

Calling an async def function does not run its body. It returns a coroutine object, a paused computation. Something must drive it: asyncio.run(hello()) at the top level, or await hello() inside another coroutine. When the unused coroutine is garbage collected Python prints RuntimeWarning: coroutine 'hello' was never awaited.

Exercise 2 · write code

Ping hosts concurrently

Write an async program. Define async def ping(host, delay) that waits await asyncio.sleep(delay) and then returns the string f"{host}: ok after {delay}s".

In async def main(), ping these concurrently with asyncio.gather:

hosts = [("alpha", 0.3), ("beta", 0.1), ("gamma", 0.2), ("delta", 0.3)]

and print each result on its own line in the order of the list above, then print all done. Run main with asyncio.run(main()). Output:

alpha: ok after 0.3s
beta: ok after 0.1s
gamma: ok after 0.2s
delta: ok after 0.3s
all done

The whole program must take less than 0.6 seconds, so the sleeps have to overlap.

File: main.py

Starting point

import asyncio


async def ping(host, delay):
    await asyncio.sleep(delay)
    return f"{host}: ok after {delay}s"


async def main():
    hosts = [("alpha", 0.3), ("beta", 0.1), ("gamma", 0.2), ("delta", 0.3)]
    for host, delay in hosts:
        print(await ping(host, delay))
    print("all done")


asyncio.run(main())

Example

expected output

alpha: ok after 0.3s
beta: ok after 0.1s
gamma: ok after 0.2s
delta: ok after 0.3s
all done
Hint 1

Awaiting each ping inside the loop waits for one to finish before starting the next: 0.9 s in total.

Hint 2

asyncio.gather(c1, c2, ...) runs the coroutines concurrently and returns their results as a list, in argument order.

Hint 3

Build the coroutines first and unpack them: await asyncio.gather(*(ping(h, d) for h, d in hosts)).

Reference solution
import asyncio


async def ping(host, delay):
    await asyncio.sleep(delay)
    return f"{host}: ok after {delay}s"


async def main():
    hosts = [("alpha", 0.3), ("beta", 0.1), ("gamma", 0.2), ("delta", 0.3)]
    results = await asyncio.gather(*(ping(h, d) for h, d in hosts))
    for line in results:
        print(line)
    print("all done")


asyncio.run(main())

Exercise 3 · quiz

The blocking call

Inside a coroutine someone wrote time.sleep(2) instead of await asyncio.sleep(2). Ten such coroutines are run with asyncio.gather. About how long does it take, and why?

Answer
  • About 20 seconds: time.sleep blocks the single event-loop thread, so no other coroutine can run in the meantime

asyncio is cooperative: coroutines only give up control at an await that actually suspends. time.sleep is an ordinary blocking call that never yields, so the whole event loop, and every other coroutine, freezes for those 2 seconds, ten times over. Blocking work belongs in await asyncio.to_thread(func, ...).

Exercise 4 · write code

Limit concurrency with a semaphore

Real services limit how many requests you may have in flight. In solution.py, write

async def fetch_all(ids, limit)

that "fetches" every id in ids concurrently, but with at most limit running at the same time, and returns the list of results in the same order as ids. Use the provided fetch(id, sem_state) coroutine for each id; it records how many fetches are running at once.

Create an asyncio.Semaphore(limit) and wrap each call as async with sem: return await fetch(i, state). Use asyncio.gather so the results keep their order. Each result is i * 10.

File: solution.py

Starting point

import asyncio


async def fetch(i, state):
    state["running"] += 1
    state["peak"] = max(state["peak"], state["running"])
    await asyncio.sleep(0.05)
    state["running"] -= 1
    return i * 10


async def fetch_all(ids, limit):
    state = {"running": 0, "peak": 0}
    return await asyncio.gather(*(fetch(i, state) for i in ids))
Hint 1

A semaphore is a counter: async with sem: waits while limit coroutines are already inside the block.

Hint 2

Write an inner async def one(i): that does async with sem: return await fetch(i, state).

Hint 3

Then return await asyncio.gather(*(one(i) for i in ids)).

Reference solution
import asyncio


async def fetch(i, state):
    state["running"] += 1
    state["peak"] = max(state["peak"], state["running"])
    await asyncio.sleep(0.05)
    state["running"] -= 1
    return i * 10


async def fetch_all(ids, limit):
    state = {"running": 0, "peak": 0}
    sem = asyncio.Semaphore(limit)

    async def one(i):
        async with sem:
            return await fetch(i, state)

    return await asyncio.gather(*(one(i) for i in ids))