Stop by Stop

Concurrency line

The asyncio event loop

One cook can handle many dishes because most of the time a dish is just waiting, and a waiting dish needs no cook.

Words to know

Stops explain each step. The film plays the whole ride.

Explain it
Route
One cookThe dishesA, B on the rackReady rackA, BThe cookOne cook, many dishesTimer boardDings when readyDelivery doorSlow things arriveHelper cooksTake the slow jobs
  • Cook takes a dish
  • A dish set aside
  • Delivery or finished dish
  • The cook is stuck
Stop 1 of 10

Two dishes go on the rack

Meet our one cook. Two dishes, A and B, go on the ready rack. A dish that must wait will step aside, so the cook never stands around.

Next stop: The cook takes dish A

All stops

The everyday version

One cook, many dishes. The cook never stands around: when a dish has to wait, it steps aside and the cook takes another, coming back when the timer board dings.

  • The dishesis the Your tasks

    Each dish is a job in progress. It pauses whenever it has to wait.

  • The ready rackis the Ready queue

    A short row of dishes that can be cooked right now.

  • The cookis the Event loop

    One person, one dish at a time. A quick turn at each, then straight on to the next.

  • The timer boardis the Selector

    Lights up when a waiting dish is ready, so the cook never checks each one.

  • The delivery dooris the Sockets

    Everything slow that comes from outside the kitchen.

  • The helper cooksis the Thread pool

    Take slow jobs out back, so the cook can keep cooking.

  • Standing and waitingis a slow job that holds up the whole kitchen

    The cook stands and waits for one dish to finish. Every other dish is ignored until then. Helper cooks should take such jobs.

Interview questions

Say the short answer first. Go deeper only if they ask.

How does asyncio get concurrency on one thread?
Short answer

Cooperative multitasking: await plus an event loop.

If they dig deeper

A Task runs until it awaits something pending, then the loop runs another ready Task. A selector (epoll or kqueue) reports which sockets are ready, so waiting costs no CPU.

Answer that loses points

“It spawns a thread per coroutine.”

What does await actually do?
Short answer

It suspends the current coroutine until the awaited thing completes.

If they dig deeper

Awaiting a coroutine runs it inline. Control returns to the loop only when something truly suspends: an unfinished future, I/O or sleep. await asyncio.sleep(0) is an explicit yield.

Answer that loses points

“Every await switches tasks.”

What happens if a coroutine calls time.sleep() or requests.get()?
Short answer

It blocks the whole loop.

If they dig deeper

No other task, timer or I/O callback runs until it returns. Use await asyncio.sleep, an async client or asyncio.to_thread. Debug mode logs callbacks over 100 ms.

Answer that loses points

“Only that task waits.”

How do you run blocking or CPU-heavy code from async code?
Short answer

asyncio.to_thread or run_in_executor.

If they dig deeper

Threads suit blocking I/O. For CPU-bound code use a ProcessPoolExecutor, because threads gain nothing under the GIL. Mind the default executor’s size.

Answer that loses points

“Just make the function async def.” That does not make it non-blocking.

What does the selector do?
Short answer

It tells the loop which file descriptors are ready.

If they dig deeper

It wraps epoll on Linux, kqueue on macOS and BSD, or select. With epoll, the kernel keeps an interest list and a ready list, so cost follows ready sockets, not total sockets. An idle loop blocks in select.

Answer that loses points

“It polls each socket in a busy loop.”

gather vs create_task vs TaskGroup?
Short answer

All run tasks concurrently. TaskGroup adds structured cancellation.

If they dig deeper

create_task schedules a Task, and you must keep a reference because the loop holds only a weak one. gather collects results. TaskGroup awaits all, raises ExceptionGroup and cancels siblings on failure.

Answer that loses points

“Calling coro() without await starts it.”

How does FastAPI use this?
Short answer

async def routes run on the loop. def routes run in a threadpool.

If they dig deeper

Sync routes run in AnyIO worker threads, whose default limiter is 40. A blocking DB call in a def route is fine, but the same call inside async def blocks the loop.

Answer that loses points

“FastAPI runs every route in a new process.”

Does asyncio.Lock protect data across threads?
Short answer

No. It is for tasks on one loop and is not thread-safe.

If they dig deeper

asyncio primitives are not thread-safe. Races across awaits are still possible: check, await, then act on a stale check.

Answer that loses points

“asyncio code cannot have race conditions.”

Cheat-sheet

Commands you will actually type.

asyncio.run(main())

Create the loop, run the coroutine, close the loop.

async with asyncio.TaskGroup() as tg:
    t1 = tg.create_task(a()); t2 = tg.create_task(b())

Structured concurrency (3.11+).

res = await asyncio.to_thread(blocking_fn, arg)

Offload blocking I/O to a thread. Context variables carry over.

loop = asyncio.get_running_loop()
await loop.run_in_executor(ProcessPoolExecutor(), cpu_fn, arg)

Offload CPU-bound work to a process.

await asyncio.sleep(0)    # explicit yield to the loop

Let others run during a long coroutine.

asyncio.run(main(), debug=True)   # or PYTHONASYNCIODEBUG=1

Logs callbacks slower than 100 ms and shows where un-awaited coroutines were created.

sem = asyncio.Semaphore(10)
async with sem: await fetch(u)

Cap how many outbound calls run at once.

async with asyncio.timeout(5): await slow()

Set a deadline (3.11+).

Sources