How it works
The explanation, with examples you can run.
Coroutines, tasks and the loop
import asyncioimport time async def fetch(name, delay): await asyncio.sleep(delay) # stands in for a network call print("done", name) return name async def main(): start = time.perf_counter() async with asyncio.TaskGroup() as tg: a = tg.create_task(fetch("a", 0.2)) b = tg.create_task(fetch("b", 0.1)) print(a.result(), b.result(), f"{time.perf_counter() - start:.1f}s") asyncio.run(main())# done b# done a# a b 0.2sCalling fetch("a", 0.2) only creates a coroutine object; nothing has run. create_task hands it to the loop to run concurrently, and asyncio.run creates the loop, runs main to completion and closes it. Both sleeps overlap, so the total is 0.2 s, not 0.3 s. The loop itself is a scheduler around the operating system's I/O readiness API (epoll, kqueue, or IOCP on Windows): it runs ready callbacks, asks the OS which sockets have data, and wakes the tasks waiting on them.
TaskGroup (3.11+) is the structured way to run tasks: the async with block waits for all of them, and if one raises, the rest are cancelled and the failures come out together as an ExceptionGroup. asyncio.gather is the older alternative. If you do use bare create_task, keep a reference to the task; the event loop only holds a weak one.
The one rule: don't block the loop
The loop can only switch tasks at an await. Any call that blocks the thread, such as time.sleep, requests.get, a synchronous database driver or a long computation, stops every task:
async def tick(): for _ in range(3): await asyncio.sleep(0.1) async def blocking(): start = time.perf_counter() t = asyncio.create_task(tick()) time.sleep(0.3) # blocks the loop: tick cannot run await t print(f"blocked: {time.perf_counter() - start:.1f}s") # blocked: 0.6s start = time.perf_counter() t = asyncio.create_task(tick()) await asyncio.to_thread(time.sleep, 0.3) await t print(f"to_thread: {time.perf_counter() - start:.1f}s") # to_thread: 0.3s asyncio.run(blocking())The ticks only started after the blocking sleep, so the work took twice as long. asyncio.to_thread runs a blocking function in a worker thread and gives you something to await, so the loop stays free. Running with PYTHONASYNCIODEBUG=1 (or asyncio.run(main(), debug=True)) logs callbacks that hold the loop for more than 100 ms.
Every wait should also have a limit. async with asyncio.timeout(5): (3.11+) cancels the block and raises TimeoutError when the time is up.
asyncio, threads or processes
- asyncio: many concurrent waits (sockets, HTTP, websockets) with libraries that are async all the way down. Lowest cost per connection, cooperative switching, and switch points you can see.
- Threads: blocking libraries you can't replace, and a modest number of concurrent waits. The OS switches threads at any moment, so shared state needs locks.
- Processes: CPU-bound Python work. Each process has its own interpreter and GIL, at the price of start-up time and copying data between processes.
They combine: an async server can hand CPU work to a ProcessPoolExecutor with loop.run_in_executor, and blocking calls to threads with asyncio.to_thread.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.