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.
Stops explain each step. The film plays the whole ride.
- Cook takes a dish
- A dish set aside
- Delivery or finished dish
- The cook is stuck
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?
Cooperative multitasking: await plus an event loop.
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.
“It spawns a thread per coroutine.”
What does await actually do?
It suspends the current coroutine until the awaited thing completes.
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.
“Every await switches tasks.”
What happens if a coroutine calls time.sleep() or requests.get()?
It blocks the whole loop.
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.
“Only that task waits.”
How do you run blocking or CPU-heavy code from async code?
asyncio.to_thread or run_in_executor.
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.
“Just make the function async def.” That does not make it non-blocking.
What does the selector do?
It tells the loop which file descriptors are ready.
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.
“It polls each socket in a busy loop.”
gather vs create_task vs TaskGroup?
All run tasks concurrently. TaskGroup adds structured cancellation.
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.
“Calling coro() without await starts it.”
How does FastAPI use this?
async def routes run on the loop. def routes run in a threadpool.
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.
“FastAPI runs every route in a new process.”
Does asyncio.Lock protect data across threads?
No. It is for tasks on one loop and is not thread-safe.
asyncio primitives are not thread-safe. Races across awaits are still possible: check, await, then act on a stale check.
“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 loopLet others run during a long coroutine.
asyncio.run(main(), debug=True) # or PYTHONASYNCIODEBUG=1Logs 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+).