asyncio is Python's answer to high-concurrency I/O-bound workloads ā web servers handling thousands of simultaneous connections, clients making hundreds of concurrent API calls. This lesson builds the mental model of the event loop and cooperative multitasking from the ground up, before the next lesson covers async/await syntax itself.
1Cooperative Multitasking: One Thread, Voluntary Yielding
asyncio achieves concurrency without multiple threads or processes by using cooperative multitasking: a single thread runs an event loop, which keeps track of every currently-running coroutine (an async def function) and switches between them ā but only at points where a coroutine explicitly says 'I'm waiting on something, you can run someone else now.' That explicit yield point is the await keyword; await asyncio.sleep(delay) doesn't just pause the current coroutine, it hands control back to the event loop, which immediately looks for other ready work (like the other fetch() calls) to run in the meantime.
This is fundamentally different from threading's preemptive model, where the operating system can interrupt a thread at essentially any point to let another thread run, whether that thread wants to yield or not. asyncio's cooperative model has no such safety net ā a coroutine that never awaits anything runs to completion without ever giving the event loop a chance to switch to other work, which is exactly what bad_task() demonstrates: its 50-million-iteration loop has no await inside it, so it monopolizes the single thread for its entire duration, freezing every other task, including ones that would otherwise be ready to make progress.
This is the single most important mental model shift required to use asyncio correctly: concurrency isn't automatic just because a function is declared async def ā a coroutine only actually shares the thread cooperatively at its await points, and a CPU-heavy coroutine with none of those points behaves exactly like a synchronous function that blocks everything else.
import asyncio
async def fetch(name: str, delay: float):
print(f"{name}: starting")
await asyncio.sleep(delay) # yields control back to the event loop
print(f"{name}: done")
async def main():
await asyncio.gather(fetch("A", 2), fetch("B", 1), fetch("C", 1.5))
asyncio.run(main())
# All three 'start' immediately; total time is ~2s (the SLOWEST), not 4.5s (the sum)~2s total ā overlapping wait times, single thread
2Why This Scales Further Than Threading for I/O
Each OS thread carries real, fixed overhead ā its own stack (typically megabytes, even if mostly unused), and the operating system's own scheduling and context-switching cost, which grows as the number of threads grows. A process handling a few dozen concurrent connections via threading is manageable; a few thousand threads for a few thousand concurrent connections becomes a genuine resource problem, in memory and in scheduling overhead alone.
asyncio's coroutines are dramatically lighter weight than OS threads ā they're plain Python objects tracked by the event loop, not OS-level constructs, so a single process can juggle tens of thousands of concurrent coroutines with a fraction of the memory and scheduling cost thousands of real threads would require. This is precisely why high-throughput async web frameworks (FastAPI, for instance) can handle far more simultaneous connections per server than an equivalent threaded design, for workloads that are genuinely I/O-bound.
The trade-off for that scalability is the requirement, introduced in the next scene, that the *entire* call chain from the event loop down needs to be async-aware ā a single blocking, synchronous call buried inside otherwise-async code freezes the whole event loop for its duration, a failure mode that simply doesn't exist for threading, where one slow synchronous call only blocks its own thread, not every other thread's work.
async def bad_task():
total = sum(i * i for i in range(50_000_000)) # NO await here
return total
# While bad_task() runs, EVERY other async task is frozen --
# there's no preemption to interrupt it, unlike OS-scheduled threadsvs. thousands of OS threads ā dramatically less memory/scheduling overhead
3The All-or-Nothing Requirement: Async-Aware Libraries Throughout
requests.get(url) is a synchronous, blocking call ā internally, it doesn't await anything, because requests was written before asyncio existed and has no concept of yielding control to an event loop. Calling it from inside an async def function doesn't make it non-blocking; it simply blocks the entire single thread the event loop is running on for however long the request takes, exactly like bad_task()'s CPU-heavy loop ā from the event loop's perspective, a blocking network call and a blocking computation are indistinguishable; both monopolize the thread with no yield point.
httpx.AsyncClient (or aiohttp), by contrast, is built specifically to integrate with the event loop: await client.get(url) performs the network operation using non-blocking I/O primitives under the hood, and yields control back to the event loop for the duration of the wait, exactly like asyncio.sleep() does ā letting other coroutines make progress during that time.
This is the practical reason asyncio adoption is often 'all or nothing' within a given call chain: mixing a single blocking library call into an otherwise fully-async codebase silently reintroduces the exact blocking behavior asyncio exists to avoid, without raising any error ā the code runs, but the concurrency benefit for that code path quietly disappears. Choosing asyncio for a project means committing to async-aware libraries (httpx over requests, asyncpg or an async ORM over a synchronous database driver) throughout the I/O-bound parts of the call chain.
import asyncio, requests # requests is SYNCHRONOUS, not async-aware
async def fetch_blocking(url):
return requests.get(url) # blocks the ENTIRE event loop while waiting
import httpx # httpx has a proper async client
async def fetch_async(url):
async with httpx.AsyncClient() as client:
return await client.get(url) # yields control while waitingBlocks the whole event loop ā no yield point exists
4Step-by-Step Breakdown
A single asyncio event loop can juggle thousands of concurrent network connections on one thread. Understanding HOW it does that is the key to writing async code that actually works.
asyncio runs everything on ONE thread, using an event loop that switches between tasks whenever one is waiting on I/O ā cooperative, not preemptive.
Checkpoint: Why does asyncio.gather(fetch("A", 2), fetch("B", 1), fetch("C", 1.5)) take about 2 seconds total, not 4.5?
- āAll three tasks run concurrently on the event loop, so total time is the slowest one, not the sum
- āEach task runs on a genuinely separate CPU core in parallel
'Cooperative' means a task must explicitly yield control (via await) ā a task that never awaits anything blocks the ENTIRE event loop, including every other task.
Checkpoint: What happens to every other async task while bad_task() runs its 50-million-iteration sum with no await inside it?
- āThey are all frozen ā cooperative multitasking means nothing else runs until bad_task() yields control or finishes
- āThey continue running normally in the background
This is why asyncio needs async-AWARE libraries throughout ā a blocking call like requests.get() (not httpx's async client) still freezes the whole loop.
With the event loop model in place, async/await is the syntax that lets you write cooperative, non-blocking code that reads almost like ordinary synchronous Python.
Prove Real Concurrent Scheduling. Finish main(): gather() schedules every coroutine up front, before awaiting any of them.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Use only async-aware libraries for I/O inside async code ā never a blocking synchronous call
A single blocking call inside an async function freezes the entire event loop for its duration, silently defeating the concurrency asyncio is meant to provide, with no error raised to warn you.
Remember that CPU-heavy work inside a coroutine still blocks every other task
async def alone provides no CPU parallelism or preemption ā genuinely CPU-bound work inside async code should be offloaded to a thread/process pool (via loop.run_in_executor) rather than run inline.
Frequent Bugs
Calling a synchronous, blocking library function (like requests.get or a synchronous DB driver) from inside an async def function, silently freezing the event loop and eliminating concurrency for that code path with no error raised.
Replace blocking calls with their async-aware equivalents (httpx.AsyncClient instead of requests, an async database driver) so the call actually yields control at its await point instead of blocking the thread.
Real-World Examples
Fetching Data From Multiple Microservices Concurrently
An API gateway needs to call three internal microservices to assemble a single response, and doing so sequentially adds each service's latency together instead of overlapping them.
import asyncio
import httpx
async def fetch_json(client: httpx.AsyncClient, url: str) -> dict:
response = await client.get(url)
return response.json()
async def get_dashboard_data():
async with httpx.AsyncClient() as client:
user, orders, inventory = await asyncio.gather(
fetch_json(client, USER_SERVICE_URL),
fetch_json(client, ORDERS_SERVICE_URL),
fetch_json(client, INVENTORY_SERVICE_URL),
)
return {"user": user, "orders": orders, "inventory": inventory}