Asyncio and Event Loops
Asyncio lets a single Python thread juggle thousands of network connections by switching to other work every time one task is waiting on something slow, like a socket read — instead of blocking and idling.
Prerequisites: The GIL and Python Threading
A market-data client that connects to fifty exchange feeds has a problem: at any moment, most of those connections are idle, just waiting for the next packet. A thread-per-connection design works but wastes memory and CPU switching between threads that are almost always doing nothing. Asyncio solves this with a single thread that never blocks — instead of a thread sitting idle waiting for one socket, it hands control to whichever other task is ready to make progress, and comes back to the waiting one later.
The mechanism that makes this work is the event loop. It maintains a list of tasks and, in a tight cycle, asks the operating system "which of these sockets have data ready?" For every socket that's ready, it resumes the corresponding task; for everything else, it moves on rather than waiting. A task written with async def and await is really telling the event loop "pause me here, and only wake me up once this specific thing is ready."
Asyncio is concurrency without parallelism: one thread, switching between many tasks, but only ever running one line of Python code at a time. It wins when a program spends most of its time waiting on I/O (network, disk, other processes) — every microsecond a normal thread would spend blocked, asyncio spends running someone else's code instead.
The loop's cycle
Worked example
import asyncio
async def fetch_quote(exchange, delay):
print(f"requesting quote from {exchange}")
await asyncio.sleep(delay) # simulates waiting on the network
print(f"got quote from {exchange}")
return f"{exchange}: 100.25"
async def main():
results = await asyncio.gather(
fetch_quote("NYSE", 2),
fetch_quote("LSE", 1),
fetch_quote("TSE", 3),
)
print(results)
asyncio.run(main())
All three fetch_quote calls start immediately — you see all three "requesting" lines print right away. Then, because await asyncio.sleep yields control back to the loop instead of blocking, the loop moves on to start the next task rather than waiting. LSE finishes first (1 second), then NYSE (2 seconds), then TSE (3 seconds) — but the whole program takes about 3 seconds total, not the 6 seconds a sequential version would take, because the three waits overlap.
What this means in practice
Asyncio is the right fit for market-data ingestion, order-routing clients, and anything juggling many concurrent network connections where each one spends most of its life idle. It is the wrong fit for CPU-bound work like pricing a large option book — since only one line of Python runs at a time, a slow calculation inside one task blocks every other task in the same event loop, network or not.
This is a different kind of concurrency from spinning up OS threads. Threads let the operating system genuinely interrupt one thread mid-instruction to run another, at the cost of real overhead per thread and the need for locks around shared data. Asyncio's tasks only ever hand control back at an explicit await, so two tasks can never step on each other's variables mid-line — there's no data race to guard against within a single event loop, which is part of why asyncio code is often easier to reason about than threaded code doing the same job, at the price of every task having to cooperate honestly.
A single blocking call inside an async def function — a synchronous requests.get(), a plain time.sleep(), a heavy NumPy computation — freezes the entire event loop, not just that one task. Because asyncio is cooperative, nothing forces a task to yield; a task that never awaits anything can starve every other task in the program, which is a much harder bug to spot than a normal thread deadlock.
Related concepts
Practice in interviews
Further reading
- Ramalho, Fluent Python (ch. 21, 'Asynchronous Programming')